Creating a new Collection model for use in organizing FileGroups.

Replacing Channels and Domains with Collection.

Moving downloading relationships from Channel to Collection.

Created domains.yaml config to manage tagged domain collections.

Adding turn-key upgrade solution in the UI.
This commit is contained in:
Roland 2025-10-29 22:16:33 -06:00 • committed by lrnselfreliance
parent 1ed0d63bac
commit a1e0fb4e92
115 changed files with 13309 additions and 986 deletions

View file

@ -142,6 +142,66 @@ jobs:
- run:
name: Stop React App
command: pkill -f npm
jest-tests:
docker:
- image: cimg/node:20.13
resource_class: medium
steps:
- checkout
- restore_cache:
key: app-{{ checksum "app/package-lock.json" }}
- run:
name: Install Dependencies
command: cd app && npm install --legacy-peer-deps
- save_cache:
key: app-{{ checksum "app/package-lock.json" }}
paths:
- ./app/node_modules
- run:
name: Run Jest Tests
command: cd app && CI=true npm test -- --watchAll=false --coverage --coverageReporters=text --coverageReporters=lcov
- store_artifacts:
path: app/coverage
destination: coverage-report
cypress-e2e-tests:
docker:
- image: cimg/node:20.18-browsers
resource_class: large
steps:
- checkout
- restore_cache:
key: app-browsers-{{ checksum "app/package-lock.json" }}
- run:
name: Install Dependencies
command: cd app && npm install --legacy-peer-deps
- save_cache:
key: app-browsers-{{ checksum "app/package-lock.json" }}
paths:
- ./app/node_modules
- run:
name: Start React App in Background
command: cd app && DISABLE_ESLINT_PLUGIN=true npm start
background: true
- run:
name: Wait for React App
command: |
for i in {1..60}; do
if curl -s http://localhost:3000 > /dev/null; then
echo "React app is ready!"
exit 0
fi
echo "Waiting for React app... ($i/60)"
sleep 2
done
echo "React app failed to start"
exit 1
- run:
name: Run Cypress Tests
command: cd app && CI=true npx cypress run
- store_artifacts:
path: app/cypress/videos
- store_artifacts:
path: app/cypress/screenshots
workflows:
wrolpi-api-tests:
@ -153,3 +213,5 @@ workflows:
wrolpi-app-test:
jobs:
- react-app-start
- jest-tests
- cypress-e2e-tests

4
.gitignore vendored
View file

@ -125,7 +125,7 @@ mapnik.xml
docker-compose.override.yml
# test directory is used as media directory, we don't want to commit what a user downloads.
test
/test
pg_data
# Directories used to build images
@ -135,3 +135,5 @@ pg_data
/pi-gen/*xz
.DS_Store
app/cypress/screenshots
app/cypress/videos

12
.mcp.json Normal file
View file

@ -0,0 +1,12 @@
{
"mcpServers": {
"postgres": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgresql://postgres:wrolpi@localhost:5432/wrolpi"
]
}
}
}

View file

@ -0,0 +1,87 @@
"""Create collection table for domain collections
Revision ID: 66407d145b76
Revises: 4f03b9548f6e
Create Date: 2025-10-26 10:57:16.462524
"""
import os
from alembic import op
import sqlalchemy as sa
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision = '66407d145b76'
down_revision = '4f03b9548f6e'
branch_labels = None
depends_on = None
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
def upgrade():
bind = op.get_bind()
session = Session(bind=bind)
# Step 1: Create new collection table (keeping channel table separate for now)
op.create_table(
'collection',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('kind', sa.String(), nullable=False),
sa.Column('directory', sa.Text(), nullable=True),
sa.Column('tag_id', sa.Integer(), nullable=True),
sa.Column('created_date', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.Column('item_count', sa.Integer(), nullable=False, server_default='0'),
sa.Column('total_size', sa.Integer(), nullable=False, server_default='0'),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['tag_id'], ['tag.id'], name='collection_tag_id_fkey'),
sa.UniqueConstraint('directory', name='uq_collection_directory')
)
# Create indexes for collection
op.create_index('idx_collection_kind', 'collection', ['kind'], unique=False)
op.create_index('idx_collection_item_count', 'collection', ['item_count'], unique=False)
op.create_index('idx_collection_total_size', 'collection', ['total_size'], unique=False)
# Step 2: Create collection_item junction table
op.create_table(
'collection_item',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('collection_id', sa.Integer(), nullable=False),
sa.Column('file_group_id', sa.Integer(), nullable=False),
sa.Column('position', sa.Integer(), nullable=False, server_default='0'),
sa.Column('added_date', sa.DateTime(), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.ForeignKeyConstraint(['collection_id'], ['collection.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['file_group_id'], ['file_group.id'], ondelete='CASCADE'),
sa.UniqueConstraint('collection_id', 'file_group_id', name='uq_collection_file_group')
)
# Create indexes for collection_item
op.create_index('idx_collection_item_collection_id', 'collection_item', ['collection_id'], unique=False)
op.create_index('idx_collection_item_file_group_id', 'collection_item', ['file_group_id'], unique=False)
op.create_index('idx_collection_item_position', 'collection_item', ['position'], unique=False)
op.create_index('idx_collection_item_collection_position', 'collection_item', ['collection_id', 'position'], unique=False)
# Ensure table owner in non-docker environments
if not DOCKERIZED:
session.execute(sa.text('ALTER TABLE public.collection OWNER TO wrolpi'))
session.execute(sa.text('ALTER TABLE public.collection_item OWNER TO wrolpi'))
def downgrade():
# Drop collection_item table and its indexes
op.drop_index('idx_collection_item_collection_position', table_name='collection_item')
op.drop_index('idx_collection_item_position', table_name='collection_item')
op.drop_index('idx_collection_item_file_group_id', table_name='collection_item')
op.drop_index('idx_collection_item_collection_id', table_name='collection_item')
op.drop_table('collection_item')
# Drop collection table and its indexes
op.drop_index('idx_collection_total_size', table_name='collection')
op.drop_index('idx_collection_item_count', table_name='collection')
op.drop_index('idx_collection_kind', table_name='collection')
op.drop_table('collection')

View file

@ -0,0 +1,95 @@
"""Add unique constraint on collection (name, kind)
This migration:
1. Removes duplicate collections (keeping the one with most items/archives)
2. Adds a unique constraint on (name, kind) to prevent future duplicates
Revision ID: add_unique_collection_name_kind
Revises: migrate_download_to_collection
Create Date: 2025-11-27
"""
import os
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision = 'add_unique_collection_name_kind'
down_revision = 'migrate_download_to_collection'
branch_labels = None
depends_on = None
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
def upgrade():
bind = op.get_bind()
session = Session(bind=bind)
print("\n" + "=" * 60)
print("Add Unique Constraint on Collection (name, kind)")
print("=" * 60 + "\n")
# Step 1: Find and remove duplicate collections
print("Step 1: Finding duplicate collections...")
# Find duplicates - keep the one with the lowest id (first created)
duplicates = session.execute(text("""
SELECT name, kind, array_agg(id ORDER BY id) as ids
FROM collection
GROUP BY name, kind
HAVING COUNT(*) > 1
""")).fetchall()
if duplicates:
print(f"Found {len(duplicates)} sets of duplicate collections")
for name, kind, ids in duplicates:
keep_id = ids[0] # Keep the first one (lowest id)
remove_ids = ids[1:] # Remove the rest
print(f" - '{name}' ({kind}): keeping id={keep_id}, removing ids={remove_ids}")
# Move any archives from duplicate collections to the one we're keeping
for remove_id in remove_ids:
session.execute(text("""
UPDATE archive SET collection_id = :keep_id
WHERE collection_id = :remove_id
"""), {'keep_id': keep_id, 'remove_id': remove_id})
# Move any downloads from duplicate collections
session.execute(text("""
UPDATE download SET collection_id = :keep_id
WHERE collection_id = :remove_id
"""), {'keep_id': keep_id, 'remove_id': remove_id})
# Move any collection items
session.execute(text("""
UPDATE collection_item SET collection_id = :keep_id
WHERE collection_id = :remove_id
"""), {'keep_id': keep_id, 'remove_id': remove_id})
# Delete the duplicate collection
session.execute(text("""
DELETE FROM collection WHERE id = :remove_id
"""), {'remove_id': remove_id})
session.commit()
print("Duplicates removed\n")
else:
print("No duplicate collections found\n")
# Step 2: Add unique constraint
print("Step 2: Adding unique constraint on (name, kind)...")
op.create_unique_constraint('uq_collection_name_kind', 'collection', ['name', 'kind'])
print("Done\n")
print("=" * 60)
print("Migration Complete")
print("=" * 60 + "\n")
if not DOCKERIZED:
session.execute(text('ALTER TABLE public.collection OWNER TO wrolpi'))
def downgrade():
op.drop_constraint('uq_collection_name_kind', 'collection', type_='unique')

View file

@ -0,0 +1,73 @@
"""Remove duplicate Channel fields delegated to Collection
This migration removes fields from Channel that now delegate to Collection:
- name (now property: channel.name → collection.name)
- directory (now property: channel.directory → collection.directory)
- tag_id (now property: channel.tag_id → collection.tag_id)
These fields were already synced to Collection in previous migration.
Revision ID: b43f70f369d0
Revises: ba98bd360b7a
Create Date: 2025-11-19 21:48:58.488850
"""
import os
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'b43f70f369d0'
down_revision = 'ba98bd360b7a'
branch_labels = None
depends_on = None
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
def upgrade():
print("\n" + "="*60)
print("Removing Duplicate Fields from Channel")
print("="*60 + "\n")
# Step 1: Drop foreign key constraint on tag_id
print("Step 1: Dropping tag_id foreign key constraint...")
op.drop_constraint('channel_tag_id_fkey', 'channel', type_='foreignkey')
print("✓ Dropped foreign key constraint\n")
# Step 2: Drop columns (data already in Collection)
print("Step 2: Dropping duplicate columns...")
op.drop_column('channel', 'name')
print(" ✓ Dropped channel.name")
op.drop_column('channel', 'directory')
print(" ✓ Dropped channel.directory")
op.drop_column('channel', 'tag_id')
print(" ✓ Dropped channel.tag_id\n")
print("="*60)
print("✓ Channel Cleanup Complete")
print(" Channels now delegate name/directory/tag to Collection")
print("="*60 + "\n")
def downgrade():
# Re-add the columns
op.add_column('channel', sa.Column('name', sa.String(), nullable=True))
op.add_column('channel', sa.Column('directory', sa.String(), nullable=True))
op.add_column('channel', sa.Column('tag_id', sa.Integer(), nullable=True))
# Re-add foreign key
op.create_foreign_key('channel_tag_id_fkey', 'channel', 'tag', ['tag_id'], ['id'], ondelete='CASCADE')
# Restore data from Collection
bind = op.get_bind()
bind.execute(text("""
UPDATE channel
SET name = collection.name,
directory = collection.directory,
tag_id = collection.tag_id
FROM collection
WHERE channel.collection_id = collection.id
"""))

View file

@ -0,0 +1,130 @@
"""Add collection_id to Channel model
This migration:
1. Adds collection_id column to channel table
2. Creates Collection records for existing Channels
3. Links Channels to their Collections
4. Adds foreign key constraint
Revision ID: ba98bd360b7a
Revises: migrate_domains_to_collections
Create Date: 2025-11-19 21:10:56.472836
"""
import os
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision = 'ba98bd360b7a'
down_revision = 'migrate_domains_to_collections'
branch_labels = None
depends_on = None
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
def upgrade():
bind = op.get_bind()
session = Session(bind=bind)
print("\n" + "="*60)
print("Channel → Collection Migration")
print("="*60 + "\n")
# Step 1: Add collection_id column to channel table (nullable initially)
print("Step 1: Adding collection_id column to channel table...")
op.add_column('channel', sa.Column('collection_id', sa.Integer(), nullable=True))
print("✓ Added collection_id column\n")
# Step 2: Create Collection records for each Channel
print("Step 2: Creating Collection records for existing Channels...")
# Fetch all channels
result = session.execute(text("""
SELECT id, name, directory, tag_id
FROM channel
ORDER BY name
"""))
channels = []
for row in result:
channels.append({
'id': row[0],
'name': row[1],
'directory': row[2],
'tag_id': row[3],
})
print(f"Found {len(channels)} Channel records to migrate")
if channels:
from wrolpi.collections import Collection
for channel in channels:
channel_id = channel['id']
channel_name = channel['name']
print(f" Processing Channel {channel_id}: {channel_name}")
# Check if Collection already exists
existing = session.query(Collection).filter_by(
name=channel_name,
kind='channel'
).first()
if existing:
print(f" Collection already exists (id={existing.id})")
collection_id = existing.id
else:
# Create Collection
collection = Collection(
name=channel_name,
kind='channel',
directory=channel['directory'], # Channels always have directory
tag_id=channel['tag_id'],
)
session.add(collection)
session.flush([collection])
collection_id = collection.id
print(f" Created Collection id={collection_id}")
# Link Channel to Collection
session.execute(
text("UPDATE channel SET collection_id = :collection_id WHERE id = :channel_id"),
{'collection_id': collection_id, 'channel_id': channel_id}
)
session.commit()
print(f"✓ Created Collections and linked Channels\n")
else:
print("No channels to migrate\n")
# Step 3: Add foreign key constraint
print("Step 3: Adding foreign key constraint...")
op.create_foreign_key(
'fk_channel_collection_id',
'channel',
'collection',
['collection_id'],
['id'],
ondelete='CASCADE'
)
print("✓ Added foreign key constraint\n")
print("="*60)
print("✓ Channel → Collection Migration Complete")
print("="*60 + "\n")
if not DOCKERIZED:
session.execute(text('ALTER TABLE public.channel OWNER TO wrolpi'))
def downgrade():
# Remove foreign key constraint
op.drop_constraint('fk_channel_collection_id', 'channel', type_='foreignkey')
# Drop collection_id column
op.drop_column('channel', 'collection_id')

View file

@ -0,0 +1,381 @@
"""Migrate Domain model to Collection model
This migration:
1. Adds collection_id column to archive table
2. Runs the data migration to populate collections
3. Makes collection_id NOT NULL
4. Drops the old domain_id column and domains table
Revision ID: migrate_domains_to_collections
Revises: 66407d145b76
Create Date: 2025-10-29
"""
import os
from typing import Dict, List
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision = 'migrate_domains_to_collections'
down_revision = '66407d145b76'
branch_labels = None
depends_on = None
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
class DomainMigrationStats:
"""Track migration statistics."""
def __init__(self):
self.domains_found = 0
self.domains_migrated = 0
self.collections_created = 0
self.collection_items_created = 0
self.archives_linked = 0
self.errors: List[str] = []
self.warnings: List[str] = []
def add_error(self, msg: str):
self.errors.append(msg)
print(f" ✗ ERROR: {msg}")
def add_warning(self, msg: str):
self.warnings.append(msg)
print(f" ⚠ WARNING: {msg}")
def print_summary(self, dry_run: bool = False):
"""Print migration summary."""
mode = "DRY-RUN" if dry_run else "ACTUAL"
print(f"\n{'='*60}")
print(f"Domain → Collection Migration Summary ({mode})")
print(f"{'='*60}")
print(f"Domains found: {self.domains_found}")
print(f"Domains migrated: {self.domains_migrated}")
print(f"Collections created: {self.collections_created}")
print(f"CollectionItems created: {self.collection_items_created}")
print(f"Archives linked: {self.archives_linked}")
if self.warnings:
print(f"\nWarnings: {len(self.warnings)}")
for warning in self.warnings:
print(f" ⚠ {warning}")
if self.errors:
print(f"\nErrors: {len(self.errors)}")
for error in self.errors:
print(f" ✗ {error}")
if not self.errors:
print(f"\n✓ Migration completed successfully")
else:
print(f"\n✗ Migration completed with {len(self.errors)} errors")
print(f"{'='*60}\n")
def get_domain_data(session: Session) -> List[Dict]:
"""Fetch all Domain records from database."""
result = session.execute(text("SELECT id, domain, directory FROM domains ORDER BY domain"))
domains = []
for row in result:
domains.append({
'id': row[0],
'domain': row[1],
'directory': row[2],
})
return domains
def get_archives_for_domain(session: Session, domain_id: int) -> List[Dict]:
"""Get all Archive IDs for a given domain."""
result = session.execute(
text("SELECT id, file_group_id FROM archive WHERE domain_id = :domain_id"),
{'domain_id': domain_id}
)
archives = []
for row in result:
if row[1]: # file_group_id must not be null
archives.append({
'archive_id': row[0],
'file_group_id': row[1],
})
return archives
def validate_domain_name(domain: str) -> bool:
"""Validate domain name format - must contain at least one dot."""
if not domain or not isinstance(domain, str):
return False
if domain.startswith('.') or domain.endswith('.'):
return False
return '.' in domain
def perform_domain_migration(session: Session, verbose: bool = True) -> DomainMigrationStats:
"""
Migrate all Domain records to Collection records.
This function is integrated into the alembic migration.
"""
from wrolpi.collections import Collection, CollectionItem
stats = DomainMigrationStats()
# Step 1: Fetch all domains
print("Step 1: Fetching Domain records...")
domains = get_domain_data(session)
stats.domains_found = len(domains)
print(f"Found {len(domains)} Domain records")
if not domains:
print("No domains to migrate")
return stats
# Step 2: Create Collections from Domains
print("\nStep 2: Creating Collection records...")
domain_to_collection: Dict[int, int] = {} # domain_id -> collection_id
for domain in domains:
domain_id = domain['id']
domain_name = domain['domain']
if verbose:
print(f" Processing Domain {domain_id}: {domain_name}")
# Validate domain name
if not validate_domain_name(domain_name):
stats.add_error(f"Invalid domain name: {repr(domain_name)} (skipping)")
continue
# Check if Collection already exists
existing = session.query(Collection).filter_by(
name=domain_name,
kind='domain'
).first()
if existing:
stats.add_warning(f"Collection already exists for domain {repr(domain_name)} (id={existing.id})")
domain_to_collection[domain_id] = existing.id
stats.domains_migrated += 1
continue
# Create Collection
collection = Collection(
name=domain_name,
kind='domain',
directory=None, # Domains are unrestricted
)
session.add(collection)
session.flush([collection])
domain_to_collection[domain_id] = collection.id
stats.collections_created += 1
stats.domains_migrated += 1
if verbose:
print(f" Created Collection id={collection.id}")
print(f"Created {stats.collections_created} Collection records")
# Step 3: Update archive.collection_id for all Archives
print("\nStep 3: Updating archive.collection_id...")
for domain in domains:
domain_id = domain['id']
domain_name = domain['domain']
if domain_id not in domain_to_collection:
# Domain was skipped due to error
continue
collection_id = domain_to_collection[domain_id]
# Update all archives for this domain
result = session.execute(
text("UPDATE archive SET collection_id = :collection_id WHERE domain_id = :domain_id"),
{'collection_id': collection_id, 'domain_id': domain_id}
)
updated_count = result.rowcount
if verbose and updated_count > 0:
print(f" Domain {repr(domain_name)}: Updated {updated_count} archives")
stats.archives_linked += updated_count
print(f"Updated {stats.archives_linked} Archives with collection_id")
# Step 4: Create CollectionItem records
print("\nStep 4: Creating CollectionItem records...")
for domain in domains:
domain_id = domain['id']
domain_name = domain['domain']
if domain_id not in domain_to_collection:
continue
collection_id = domain_to_collection[domain_id]
# Get all archives for this domain
archives = get_archives_for_domain(session, domain_id)
if not archives:
if verbose:
print(f" Domain {repr(domain_name)}: No archives found")
continue
if verbose:
print(f" Domain {repr(domain_name)}: {len(archives)} archives")
for idx, archive in enumerate(archives, start=1):
file_group_id = archive['file_group_id']
archive_id = archive['archive_id']
# Check if CollectionItem already exists
existing_item = session.query(CollectionItem).filter_by(
collection_id=collection_id,
file_group_id=file_group_id
).first()
if existing_item:
if verbose:
print(f" Archive {archive_id} already linked to collection")
continue
# Create CollectionItem
item = CollectionItem(
collection_id=collection_id,
file_group_id=file_group_id,
position=idx
)
session.add(item)
stats.collection_items_created += 1
session.flush()
print(f"Created {stats.collection_items_created} CollectionItem records")
# Step 5: Skip config export during migration
# The config export requires a separate database session, but we're inside
# an alembic transaction. The application will export on startup instead.
print("\nStep 5: Skipping domains.yaml export during migration...")
print(" (Config will be exported on next application startup)")
return stats
def upgrade():
"""
Migrate Domains to Collections.
This is a one-way migration - there is no downgrade path because:
- Domain data is transformed into Collection data
- Once migrated, the system uses Collections exclusively
- Downgrade would require reimplementing Domain model
"""
bind = op.get_bind()
session = Session(bind=bind)
print("\n" + "="*60)
print("DOMAIN → COLLECTION MIGRATION")
print("="*60 + "\n")
# Step 1: Add collection_id column to archive table (nullable initially)
print("Step 1: Adding collection_id column to archive table...")
op.add_column('archive',
sa.Column('collection_id', sa.Integer(), nullable=True))
# Add foreign key to collection table
op.create_foreign_key(
'archive_collection_id_fkey',
'archive', 'collection',
['collection_id'], ['id'],
ondelete='CASCADE'
)
print("✓ Added collection_id column\n")
# Step 2: Run data migration script
print("Step 2: Running data migration script...")
print("This will:")
print(" - Create Collection records from Domain records")
print(" - Create CollectionItem records for Archives\n")
try:
# Commit the schema changes before running migration
session.commit()
# Run the data migration
stats = perform_domain_migration(session, verbose=True)
# Print summary
stats.print_summary(dry_run=False)
if stats.errors:
raise Exception(f"Migration completed with {len(stats.errors)} errors. See logs above.")
except Exception as e:
print(f"\n✗ Migration failed: {e}")
raise
# Step 3: Make collection_id NOT NULL
print("\nStep 3: Making collection_id NOT NULL...")
# Check if any archives don't have a collection_id
result = session.execute(sa.text(
"SELECT COUNT(*) FROM archive WHERE collection_id IS NULL"
))
null_count = result.scalar()
if null_count > 0:
raise Exception(
f"Cannot make collection_id NOT NULL: {null_count} archives have NULL collection_id. "
f"The migration script should have assigned all archives to collections."
)
op.alter_column('archive', 'collection_id', nullable=False)
print("✓ collection_id is now NOT NULL\n")
# Step 4: Drop old domain_id column and foreign key
print("Step 4: Dropping domain_id column and foreign key...")
op.drop_constraint('archive_domain_id_fkey', 'archive', type_='foreignkey')
op.drop_column('archive', 'domain_id')
print("✓ Dropped domain_id column\n")
# Step 5: Drop domains table
print("Step 5: Dropping domains table...")
op.drop_table('domains')
print("✓ Dropped domains table\n")
# Ensure table ownership in non-docker environments
if not DOCKERIZED:
print("Setting table ownership...")
session.execute(sa.text('ALTER TABLE public.archive OWNER TO wrolpi'))
session.commit()
print("="*60)
print("MIGRATION COMPLETED SUCCESSFULLY")
print("="*60 + "\n")
def downgrade():
"""
No downgrade path for this migration.
This is intentional because:
1. Domain → Collection is a one-way data transformation
2. The Domain model is being removed from the codebase
3. Rolling back would require:
- Recreating Domain model code
- Reversing the data transformation (Collection → Domain)
- Handling edge cases where Collections have features Domains didn't
If you need to rollback, you should:
1. Restore from a database backup taken before migration
2. Re-index all files to rebuild Domain records
"""
raise NotImplementedError(
"This migration has no downgrade path. "
"To rollback, reset database and re-index files."
)

View file

@ -0,0 +1,110 @@
"""Migrate Download.channel_id to Download.collection_id
This migration:
1. Adds collection_id column to download table
2. Migrates existing channel_id references to collection_id via Channel.collection_id
3. Drops channel_id column and foreign key
Revision ID: migrate_download_to_collection
Revises: b43f70f369d0
Create Date: 2025-11-27
"""
import os
from alembic import op
import sqlalchemy as sa
from sqlalchemy import text
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision = 'migrate_download_to_collection'
down_revision = 'b43f70f369d0'
branch_labels = None
depends_on = None
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
def upgrade():
bind = op.get_bind()
session = Session(bind=bind)
print("\n" + "=" * 60)
print("Download channel_id -> collection_id Migration")
print("=" * 60 + "\n")
# Step 1: Add collection_id column (nullable initially)
print("Step 1: Adding collection_id column to download table...")
op.add_column('download', sa.Column('collection_id', sa.Integer(), nullable=True))
print("Done\n")
# Step 2: Migrate data - lookup collection_id through channel
print("Step 2: Migrating channel_id to collection_id...")
result = session.execute(text("""
UPDATE download
SET collection_id = channel.collection_id
FROM channel
WHERE download.channel_id = channel.id
AND download.channel_id IS NOT NULL
"""))
print(f"Updated {result.rowcount} download records\n")
# Step 3: Add foreign key constraint
print("Step 3: Adding foreign key constraint...")
op.create_foreign_key(
'fk_download_collection_id',
'download',
'collection',
['collection_id'],
['id'],
ondelete='SET NULL'
)
print("Done\n")
# Step 4: Create index for performance
print("Step 4: Creating index on collection_id...")
op.create_index('idx_download_collection_id', 'download', ['collection_id'])
print("Done\n")
# Step 5: Drop old channel_id foreign key and column
print("Step 5: Dropping channel_id column...")
op.drop_constraint('download_channel_id_fkey', 'download', type_='foreignkey')
op.drop_column('download', 'channel_id')
print("Done\n")
print("=" * 60)
print("Migration Complete")
print("=" * 60 + "\n")
if not DOCKERIZED:
session.execute(text('ALTER TABLE public.download OWNER TO wrolpi'))
def downgrade():
bind = op.get_bind()
session = Session(bind=bind)
# Add back channel_id column
op.add_column('download', sa.Column('channel_id', sa.Integer(), nullable=True))
# Migrate collection_id back to channel_id
session.execute(text("""
UPDATE download
SET channel_id = channel.id
FROM channel
WHERE download.collection_id = channel.collection_id
AND download.collection_id IS NOT NULL
"""))
# Add foreign key constraint
op.create_foreign_key(
'download_channel_id_fkey',
'download',
'channel',
['channel_id'],
['id']
)
# Drop collection_id
op.drop_index('idx_download_collection_id', 'download')
op.drop_constraint('fk_download_collection_id', 'download', type_='foreignkey')
op.drop_column('download', 'collection_id')

View file

@ -1,6 +1,14 @@
const { defineConfig } = require("cypress");
module.exports = defineConfig({
e2e: {
// Use HTTP for CI, HTTPS for local development
baseUrl: process.env.CI ? 'http://localhost:3000' : 'https://localhost:8443',
specPattern: 'cypress/e2e/**/*.cy.js',
supportFile: 'cypress/support/e2e.js',
video: process.env.CI ? true : false,
screenshotOnRunFailure: true,
},
component: {
devServer: {
framework: "create-react-app",

View file

@ -0,0 +1,270 @@
describe('Domain Editing Workflow', () => {
beforeEach(() => {
// Suppress uncaught exceptions from the application (API errors are thrown as unhandled rejections)
cy.on('uncaught:exception', (err) => {
// Return false to prevent Cypress from failing the test
// These are expected API errors in error handling tests
return false;
});
// Mock directory search endpoint - DirectorySearch component makes this call automatically
cy.intercept('POST', '/api/files/search_directories', {
statusCode: 200,
body: {is_dir: true, directories: [], channel_directories: [], domain_directories: []}
}).as('searchDirectories');
// Mock tags endpoint - needed for TagsSelector component
cy.intercept('GET', '/api/tags', {
statusCode: 200,
body: {
tags: [
{id: 1, name: 'News', color: '#ff0000'},
{id: 2, name: 'Tech', color: '#00ff00'}
]
}
}).as('getTags');
// Mock the collections list for domains
cy.intercept('GET', '/api/collections?kind=domain', {
statusCode: 200,
body: {
collections: [
{
id: 1,
domain: 'example.com',
archive_count: 42,
size: 1024000,
tag_name: null,
directory: null,
can_be_tagged: false,
description: 'Example domain'
}
],
totals: {collections: 1},
metadata: {
kind: 'domain',
columns: [
{key: 'domain', label: 'Domain', sortable: true},
{key: 'archive_count', label: 'Archives', sortable: true, align: 'right'},
{key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes'},
{key: 'tag_name', label: 'Tag', sortable: true},
{key: 'actions', label: 'Manage', sortable: false, type: 'actions'},
],
fields: [
{key: 'directory', label: 'Directory', type: 'text', placeholder: 'Optional directory path'},
{key: 'tag_name', label: 'Tag', type: 'tag', placeholder: 'Select or create tag', depends_on: 'directory'},
{key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'},
],
routes: {
list: '/archive/domains',
edit: '/archive/domain/:id/edit',
search: '/archive',
},
}
}
}).as('getDomains');
});
it('completes full edit workflow: add directory and tag', () => {
// Mock collection details endpoint BEFORE navigation
cy.intercept('GET', '/api/collections/1', {
statusCode: 200,
body: {
collection: {
id: 1,
domain: 'example.com',
directory: '',
tag_name: null,
description: '',
can_be_tagged: false,
archive_count: 42,
size: 1024000
}
}
}).as('getDomain');
// Start at domains list
cy.visit('/archive/domains');
cy.wait('@getDomains');
// Click edit on first domain
cy.get('table tbody tr').first().within(() => {
cy.contains('Edit').click();
});
// Should navigate to edit page
cy.url().should('include', '/archive/domain/1/edit');
cy.wait('@getDomain');
// Page should load with domain name
cy.contains('h1', 'example.com').should('exist');
// Set directory - DirectorySearch component uses input inside field
cy.contains('label', 'Directory').parent().find('input').clear().type('archive/example.com');
// Set description - Semantic UI TextArea
cy.contains('label', 'Description').parent().find('textarea').clear().type('Updated example domain');
// Mock tags endpoint
cy.intercept('GET', '/api/tags', {
statusCode: 200,
body: {
tags: [
{id: 1, name: 'News', color: '#ff0000'},
{id: 2, name: 'Tech', color: '#00ff00'}
]
}
}).as('getTags');
// Tag selector should now be enabled (because directory is set)
// This depends on the actual implementation of CollectionEditForm
// Mock update endpoint
cy.intercept('PUT', '/api/collections/1', {
statusCode: 200,
body: {
success: true,
collection: {
id: 1,
name: 'example.com',
directory: 'archive/example.com',
tag_name: null,
description: 'Updated example domain',
can_be_tagged: true,
}
}
}).as('updateDomain');
// Save changes
cy.contains('button', 'Save').click();
cy.wait('@updateDomain');
// Should show success toast and stay on edit page (page refreshes data after save)
cy.contains('Domain Updated').should('be.visible');
cy.url().should('include', '/archive/domain/1/edit');
});
it('shows validation errors for invalid data', () => {
// Mock domain details
cy.intercept('GET', '/api/collections/1', {
statusCode: 200,
body: {
collection: {
id: 1,
domain: 'example.com',
directory: 'archive/example.com',
tag_name: null,
description: 'Example domain',
can_be_tagged: true,
archive_count: 42,
size: 1024000
}
}
}).as('getDomain');
cy.visit('/archive/domain/1/edit');
cy.wait('@getDomain');
// Try to save with invalid directory
cy.intercept('PUT', '/api/collections/1', {
statusCode: 400,
body: {
error: 'Invalid directory path',
cause: {
code: 'INVALID_DIRECTORY'
}
}
}).as('invalidUpdate');
cy.contains('label', 'Directory').parent().find('input').clear().type('/invalid/absolute/path');
cy.contains('button', 'Save').click();
cy.wait('@invalidUpdate');
// Should show error message
cy.contains('Invalid directory path').should('be.visible');
// Should stay on edit page
cy.url().should('include', '/edit');
});
it('allows navigating back without saving using Back button', () => {
cy.intercept('GET', '/api/collections/1', {
statusCode: 200,
body: {
collection: {
id: 1,
domain: 'example.com',
directory: '',
tag_name: null,
description: 'Example domain',
can_be_tagged: false,
archive_count: 42,
size: 1024000
}
}
}).as('getDomain');
// Mock PUT endpoint but don't expect it to be called
cy.intercept('PUT', '/api/collections/1', {
statusCode: 200,
body: {success: true, domain: {}}
}).as('updateDomain');
cy.visit('/archive/domain/1/edit');
cy.wait('@getDomain');
// Make some changes
cy.contains('label', 'Description').parent().find('textarea').clear().type('Changed description');
// Click Back button (the page uses BackButton, not Cancel)
cy.contains('button', 'Back').click();
// Should navigate back without saving
// Note: Since we don't have browser history in test, we just verify no save was made
cy.get('@updateDomain.all').should('have.length', 0);
});
it('shows form after domain data loads', () => {
cy.intercept('GET', '/api/collections/1', {
statusCode: 200,
body: {
collection: {
id: 1,
domain: 'example.com',
directory: null,
tag_name: null,
description: 'Example domain',
can_be_tagged: false
}
}
}).as('getDomain');
cy.visit('/archive/domain/1/edit');
cy.wait('@getDomain');
// Form should be visible with domain name in title
cy.contains('h1', 'example.com').should('exist');
// Form fields should be present
cy.contains('label', 'Directory').should('exist');
cy.contains('label', 'Description').should('exist');
});
it('handles 404 when domain not found', () => {
cy.intercept('GET', '/api/collections/999', {
statusCode: 404,
body: {
error: 'Domain collection with ID 999 not found'
}
}).as('domainNotFound');
cy.visit('/archive/domain/999/edit');
cy.wait('@domainNotFound');
// Should show error message
cy.contains('not found').should('be.visible');
});
});

View file

@ -0,0 +1,154 @@
describe('Domains List Page', () => {
beforeEach(() => {
// Mock the collections API response for domains
cy.intercept('GET', '/api/collections?kind=domain', {
statusCode: 200,
body: {
collections: [
{
id: 1,
domain: 'example.com',
archive_count: 42,
size: 1024000,
tag_name: 'News',
directory: '/media/archives/example.com',
can_be_tagged: true,
description: 'Example domain'
},
{
id: 2,
domain: 'test.org',
archive_count: 15,
size: 512000,
tag_name: null,
directory: null,
can_be_tagged: false,
description: null
}
],
totals: {collections: 2},
metadata: {
kind: 'domain',
columns: [
{key: 'domain', label: 'Domain', sortable: true},
{key: 'archive_count', label: 'Archives', sortable: true, align: 'right'},
{key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes'},
{key: 'tag_name', label: 'Tag', sortable: true},
{key: 'actions', label: 'Manage', sortable: false, type: 'actions'}
],
fields: [
{key: 'directory', label: 'Directory', type: 'text', placeholder: 'Optional directory path'},
{key: 'tag_name', label: 'Tag', type: 'tag', placeholder: 'Select or create tag', depends_on: 'directory'},
{key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'}
],
routes: {
list: '/archive/domains',
edit: '/archive/domain/:id/edit',
search: '/archive'
},
messages: {
no_directory: 'Set a directory to enable tagging',
tag_will_move: 'Tagging will move files to a new directory'
}
}
}
}).as('getDomains');
// Visit the domains page
cy.visit('/archive/domains');
cy.wait('@getDomains');
});
it('displays domains page without errors', () => {
// DomainsPage doesn't have h1, check for the search input instead
cy.get('input[placeholder="Domain filter..."]').should('exist');
});
it('renders CollectionTable component', () => {
// Check that the table exists
cy.get('table').should('exist');
});
it('shows search input', () => {
cy.get('input[placeholder="Domain filter..."]').should('exist');
});
it('shows all domains from API', () => {
cy.get('table tbody tr').should('have.length', 2);
});
it('displays domain names', () => {
cy.get('table tbody tr').first().should('contain', 'example.com');
cy.get('table tbody tr').last().should('contain', 'test.org');
});
it('displays Edit buttons in Manage column', () => {
cy.get('table tbody tr').first().within(() => {
cy.get('a').contains('Edit').should('exist');
});
});
it('Edit button has correct styling', () => {
cy.get('table tbody tr').first().within(() => {
cy.get('a').contains('Edit').should('have.class', 'ui');
cy.get('a').contains('Edit').should('have.class', 'button');
cy.get('a').contains('Edit').should('have.class', 'secondary');
});
});
it('navigates to domain edit page when edit clicked', () => {
// Mock the edit page's API calls to prevent "Failed to fetch" errors
cy.intercept('GET', '/api/collections/1', {
statusCode: 200,
body: {
collection: {
id: 1,
domain: 'example.com',
directory: '/media/archives/example.com',
tag_name: 'News',
description: 'Example domain'
}
}
}).as('getDomain');
// Mock directory search to prevent errors
cy.intercept('POST', '/api/files/search_directories', {
statusCode: 200,
body: {is_dir: true, directories: [], channel_directories: [], domain_directories: []}
}).as('searchDirs');
cy.get('table tbody tr').first().within(() => {
cy.get('a').contains('Edit').click();
});
cy.url().should('include', '/archive/domain/1/edit');
});
it('filters domains with search', () => {
cy.get('input[placeholder="Domain filter..."]').type('example');
cy.get('table tbody tr').should('have.length', 1);
cy.get('table tbody tr').should('contain', 'example.com');
});
it('shows empty message when no domains', () => {
cy.intercept('GET', '/api/collections?kind=domain', {
statusCode: 200,
body: {
collections: [],
totals: {collections: 0},
metadata: {
kind: 'domain',
columns: []
}
}
}).as('getEmptyDomains');
cy.visit('/archive/domains');
cy.wait('@getEmptyDomains');
cy.contains('No domains yet').should('exist');
});
it('does not show "New Domain" button (domains are auto-created)', () => {
cy.contains('button', 'New Domain').should('not.exist');
});
});

View file

@ -0,0 +1,299 @@
describe('DirectorySearch Integration Tests', () => {
const mockSearchResponse = {
is_dir: false,
directories: [
{path: 'videos/nature'},
{path: 'videos/tech'},
{path: 'videos/cooking'}
],
channel_directories: [
{path: 'videos/channels/news', name: 'News Channel'},
{path: 'videos/channels/tech', name: 'Tech Reviews'}
],
domain_directories: [
{path: 'archive/example.com', domain: 'example.com'},
{path: 'archive/test.org', domain: 'test.org'}
]
};
const mockEmptyResponse = {
is_dir: false,
directories: [],
channel_directories: [],
domain_directories: []
};
const mockExistingDirResponse = {
is_dir: true,
directories: [
{path: 'videos/nature/wildlife'},
{path: 'videos/nature/ocean'}
],
channel_directories: [],
domain_directories: []
};
beforeEach(() => {
// Mock the directory search API
cy.intercept('POST', '/api/files/search_directories', (req) => {
const {path} = req.body;
if (path === 'videos') {
req.reply({statusCode: 200, body: mockSearchResponse});
} else if (path === 'empty') {
req.reply({statusCode: 200, body: mockEmptyResponse});
} else if (path === 'videos/nature') {
req.reply({statusCode: 200, body: mockExistingDirResponse});
} else {
req.reply({statusCode: 200, body: mockSearchResponse});
}
}).as('searchDirectories');
});
context('Standalone DirectorySearch Behavior', () => {
beforeEach(() => {
// Visit a page with DirectorySearch (using domain edit as example)
cy.intercept('GET', '/api/collections/1', {
statusCode: 200,
body: {
collection: {
id: 1,
name: 'example.com',
kind: 'domain',
directory: '',
description: '',
tag_name: null
}
}
}).as('getDomain');
cy.intercept('GET', '/api/collections?kind=domain', {
statusCode: 200,
body: {
collections: [],
totals: {collections: 0},
metadata: {
kind: 'domain',
columns: [],
fields: [
{key: 'directory', label: 'Directory', type: 'text', required: false}
],
routes: {},
messages: {}
}
}
}).as('getMetadata');
cy.visit('/archive/domain/1/edit');
cy.wait('@getDomain');
cy.wait('@getMetadata');
});
it('types in search box and sees results', () => {
// Find the directory input
cy.contains('label', 'Directory')
.parent()
.find('input')
.type('videos');
cy.wait('@searchDirectories');
// Should see results in dropdown
cy.contains('videos/nature').should('be.visible');
cy.contains('videos/tech').should('be.visible');
});
it('clicks result and value updates', () => {
cy.contains('label', 'Directory')
.parent()
.find('input')
.type('videos');
cy.wait('@searchDirectories');
// Click a result
cy.contains('videos/nature').click();
// Input should show selected value
cy.contains('label', 'Directory')
.parent()
.find('input')
.should('have.value', 'videos/nature');
});
it('categories are visually distinct', () => {
cy.contains('label', 'Directory')
.parent()
.find('input')
.type('videos');
cy.wait('@searchDirectories');
// Check that categories exist
cy.contains('Directories').should('be.visible');
cy.contains('Channels').should('be.visible');
cy.contains('Domains').should('be.visible');
});
it('result descriptions appear correctly', () => {
cy.contains('label', 'Directory')
.parent()
.find('input')
.type('videos');
cy.wait('@searchDirectories');
// Channel should show name as description
cy.contains('News Channel').should('be.visible');
// Domain should show domain as description
cy.contains('example.com').should('be.visible');
});
it('shows "New Directory" for new paths', () => {
cy.contains('label', 'Directory')
.parent()
.find('input')
.type('empty');
cy.wait('@searchDirectories');
// Should show "New Directory" option
cy.contains('New Directory').should('be.visible');
});
});
context('DestinationForm Workflow', () => {
beforeEach(() => {
// Set up domain edit page as a form with DestinationForm
cy.intercept('GET', '/api/collections/2', {
statusCode: 200,
body: {
collection: {
id: 2,
name: 'test.com',
kind: 'domain',
directory: 'archive/test.com',
description: '',
tag_name: null
}
}
}).as('getDomain');
cy.intercept('GET', '/api/collections?kind=domain', {
statusCode: 200,
body: {
collections: [],
totals: {collections: 0},
metadata: {
kind: 'domain',
columns: [],
fields: [
{key: 'directory', label: 'Directory', type: 'text', required: true}
],
routes: {},
messages: {}
}
}
}).as('getMetadata');
cy.visit('/archive/domain/2/edit');
cy.wait('@getDomain');
cy.wait('@getMetadata');
});
it('starts with existing directory value', () => {
cy.contains('label', 'Directory')
.parent()
.find('input')
.should('have.value', 'archive/test.com');
});
it('types partial path and sees suggestions', () => {
cy.contains('label', 'Directory')
.parent()
.find('input')
.clear()
.type('videos');
cy.wait('@searchDirectories');
// Should see suggestions
cy.contains('videos/nature').should('be.visible');
});
it('selects channel directory and form updates', () => {
cy.contains('label', 'Directory')
.parent()
.find('input')
.clear()
.type('videos');
cy.wait('@searchDirectories');
// Select a channel directory
cy.contains('News Channel').click();
// Form should update
cy.contains('label', 'Directory')
.parent()
.find('input')
.should('have.value', 'videos/channels/news');
});
});
context('Real-World Scenarios', () => {
it('handles API returning empty results', () => {
cy.intercept('GET', '/api/collections/3', {
statusCode: 200,
body: {
collection: {
id: 3,
name: 'empty.com',
kind: 'domain',
directory: '',
description: '',
tag_name: null
}
}
}).as('getDomain');
cy.intercept('GET', '/api/collections?kind=domain', {
statusCode: 200,
body: {
collections: [],
totals: {collections: 0},
metadata: {
kind: 'domain',
columns: [],
fields: [
{key: 'directory', label: 'Directory', type: 'text', required: false}
],
routes: {},
messages: {}
}
}
}).as('getMetadata');
cy.visit('/archive/domain/3/edit');
cy.wait('@getDomain');
cy.wait('@getMetadata');
// Type path that returns empty results
cy.contains('label', 'Directory')
.parent()
.find('input')
.type('empty');
cy.wait('@searchDirectories');
// Should show "New Directory" option
cy.contains('New Directory').should('be.visible');
});
});
});

View file

@ -0,0 +1,63 @@
{
"collections": [
{
"id": 1,
"name": "My Tech Channel",
"video_count": 100,
"size": 5000000000,
"tag_name": "Tech",
"directory": "videos/channels/tech",
"download_frequency": "daily",
"url": "https://youtube.com/c/tech",
"rss_url": "https://youtube.com/feeds/tech"
},
{
"id": 2,
"name": "News Channel",
"video_count": 50,
"size": 2500000000,
"tag_name": "News",
"directory": "videos/channels/news",
"download_frequency": "weekly",
"url": "https://youtube.com/c/news",
"rss_url": null
},
{
"id": 3,
"name": "Science Channel",
"video_count": 75,
"size": 3750000000,
"tag_name": null,
"directory": "videos/channels/science",
"download_frequency": null,
"url": null,
"rss_url": null
}
],
"totals": {
"channels": 3
},
"metadata": {
"kind": "channel",
"columns": [
{"key": "name", "label": "Name", "sortable": true},
{"key": "tag_name", "label": "Tag", "sortable": true},
{"key": "video_count", "label": "Videos", "sortable": true},
{"key": "download_frequency", "label": "Download Frequency", "sortable": true},
{"key": "size", "label": "Size", "sortable": true, "format": "bytes"},
{"key": "actions", "label": "Manage", "sortable": false, "type": "actions"}
],
"fields": [
{"key": "name", "label": "Channel Name", "type": "text", "required": true},
{"key": "directory", "label": "Directory", "type": "path", "required": true},
{"key": "url", "label": "URL", "type": "url", "optional": true},
{"key": "tag_name", "label": "Tag", "type": "tag", "optional": true},
{"key": "download_missing_data", "label": "Download Missing Data", "type": "boolean", "optional": true}
],
"routes": {
"list": "/videos/channels",
"edit": "/videos/channel/:id/edit",
"search": "/videos/channel/:id/video"
}
}
}

View file

@ -0,0 +1,62 @@
{
"collections": [
{
"id": 1,
"domain": "example.com",
"archive_count": 42,
"size": 1024000,
"tag_name": "News",
"directory": "archive/example.com",
"can_be_tagged": true,
"description": "News from Example.com"
},
{
"id": 2,
"domain": "test.org",
"archive_count": 15,
"size": 512000,
"tag_name": null,
"directory": null,
"can_be_tagged": false,
"description": "Test archives"
},
{
"id": 3,
"domain": "demo.net",
"archive_count": 8,
"size": 256000,
"tag_name": "Tech",
"directory": "archive/demo.net",
"can_be_tagged": true,
"description": "Tech demos"
}
],
"totals": {
"domains": 3
},
"metadata": {
"kind": "domain",
"columns": [
{"key": "domain", "label": "Domain", "sortable": true},
{"key": "archive_count", "label": "URLs", "sortable": true, "align": "right"},
{"key": "size", "label": "Size", "sortable": true, "align": "right", "format": "bytes"},
{"key": "tag_name", "label": "Tag", "sortable": true},
{"key": "actions", "label": "Manage", "sortable": false, "type": "actions"}
],
"fields": [
{"key": "directory", "label": "Directory", "type": "text", "placeholder": "Optional directory path"},
{"key": "tag_name", "label": "Tag", "type": "tag", "placeholder": "Select or create tag", "depends_on": "directory"},
{"key": "description", "label": "Description", "type": "textarea", "placeholder": "Optional description"}
],
"routes": {
"list": "/archive/domains",
"edit": "/archive/domain/:id/edit",
"search": "/archive",
"searchParam": "domain"
},
"messages": {
"no_directory": "Set a directory to enable tagging",
"tag_will_move": "Tagging will move files to a new directory"
}
}
}

View file

@ -0,0 +1,9 @@
// ***********************************************************
// This file is used to load the support and commands for e2e tests
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
// Alternatively you can use CommonJS syntax:
// require('./commands')

153
app/package-lock.json generated
View file

@ -29,8 +29,18 @@
"semantic-ui-react": "^2.1.4",
"three": "^0.154",
"web-vitals": "^2.1.4"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
"dev": true,
"license": "MIT"
},
"node_modules/@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@ -3728,6 +3738,33 @@
"node": ">=8"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz",
@ -6365,6 +6402,13 @@
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssdb": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz",
@ -13041,6 +13085,16 @@
"node": ">=6"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/mini-css-extract-plugin": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz",
@ -13699,9 +13753,10 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
@ -15751,6 +15806,20 @@
"node": ">=6.0.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@ -16985,6 +17054,19 @@
"node": ">=6"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@ -18880,6 +18962,12 @@
}
},
"dependencies": {
"@adobe/css-tools": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
"dev": true
},
"@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@ -21357,6 +21445,28 @@
}
}
},
"@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"requires": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"dependencies": {
"dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true
}
}
},
"@testing-library/react": {
"version": "13.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-13.4.0.tgz",
@ -23310,6 +23420,12 @@
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw=="
},
"css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true
},
"cssdb": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz",
@ -28143,6 +28259,12 @@
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
},
"min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true
},
"mini-css-extract-plugin": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz",
@ -28605,9 +28727,9 @@
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"picomatch": {
"version": "2.3.1",
@ -29902,6 +30024,16 @@
"minimatch": "^3.0.5"
}
},
"redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"requires": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
}
},
"regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@ -30830,6 +30962,15 @@
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="
},
"strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"requires": {
"min-indent": "^1.0.0"
}
},
"strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",

View file

@ -27,9 +27,11 @@
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"build": "BROWSERSLIST_IGNORE_OLD_DATA=true react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"eject": "react-scripts eject",
"cy:run": "cypress run",
"cy:open": "cypress open"
},
"eslintConfig": {
"extends": [
@ -47,5 +49,8 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1"
}
}

View file

@ -118,6 +118,14 @@ function handleEvents(events) {
eventToast('Screenshot Generation Failed!', message, 'error', 5000);
}
if (event === 'upgrade_started') {
eventToast('Upgrade Started', message, 'info', 10000);
// Redirect to maintenance page after a short delay
setTimeout(() => {
window.location.href = '/maintenance.html';
}, 1000);
}
if (subject) {
newestEvents[subject] = dt;
}

View file

@ -385,6 +385,11 @@ export function AddTagsButton({
const [loading, setLoading] = React.useState(false);
const [localTags, setLocalTags] = React.useState(selectedTagNames);
// Sync localTags with selectedTagNames when it changes from parent
React.useEffect(() => {
setLocalTags(selectedTagNames);
}, [selectedTagNames]);
const active = anyTag || (selectedTagNames && selectedTagNames.length > 0);
const handleOpen = (e) => {

View file

@ -1,7 +1,7 @@
import {emptyToNull} from "./components/Common";
import {toast} from "react-semantic-toasts-2";
import _ from "lodash";
import {API_URI, ARCHIVES_API, DEFAULT_LIMIT, Downloaders, OTP_API, VIDEOS_API, ZIM_API} from "./components/Vars";
import {API_URI, ARCHIVES_API, COLLECTIONS_API, DEFAULT_LIMIT, Downloaders, OTP_API, VIDEOS_API, ZIM_API} from "./components/Vars";
function timeoutPromise(ms, promise) {
// Create a timeout wrapper around a promise. If the timeout is reached, throw an error. Otherwise, return
@ -719,10 +719,10 @@ export async function searchArchives(offset, limit, domain, searchStr, order, ta
}
export async function fetchDomains() {
const response = await apiGet(`${ARCHIVES_API}/domains`);
const response = await apiGet(`${COLLECTIONS_API}?kind=domain`);
if (response.ok) {
let data = await response.json();
return [data['domains'], data['totals']['domains']];
return [data['collections'], data['totals']['collections'], data['metadata']];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Domains. See server logs.');
toast({
@ -734,6 +734,137 @@ export async function fetchDomains() {
}
}
export async function fetchChannels() {
const response = await apiGet(`${COLLECTIONS_API}?kind=channel`);
if (response.ok) {
let data = await response.json();
return [data['collections'], data['totals']['collections'], data['metadata']];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Channels. See server logs.');
toast({
type: 'error',
title: 'Channels Error',
description: message,
time: 5000,
});
}
}
export async function getDomain(domainId) {
const response = await apiGet(`${COLLECTIONS_API}/${domainId}`);
if (response.ok) {
const data = await response.json();
return data['collection'];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Domain. See server logs.');
toast({
type: 'error',
title: 'Domain Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function updateDomain(domainId, updates) {
updates = emptyToNull(updates);
const response = await apiPut(`${COLLECTIONS_API}/${domainId}`, updates);
if (response.ok) {
const data = await response.json();
return data['collection'];
} else {
const message = await getErrorMessage(response, 'Unable to update Domain. See server logs.');
toast({
type: 'error',
title: 'Domain Update Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function getCollectionTagInfo(collectionId, tagName) {
const body = {
tag_name: tagName,
}
const response = await apiPost(`${COLLECTIONS_API}/${collectionId}/tag_info`, body);
if (response.ok) {
const data = await response.json();
return data;
} else {
const message = await getErrorMessage(response, 'Unable to get tag info. See server logs.');
toast({
type: 'error',
title: 'Tag Info Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function tagDomain(domainId, tagName, directory) {
const body = {
tag_name: tagName,
}
if (directory) {
body['directory'] = directory;
}
const response = await apiPost(`${COLLECTIONS_API}/${domainId}/tag`, body);
if (response.ok) {
const data = await response.json();
return data;
} else {
const message = await getErrorMessage(response, 'Unable to tag Domain. See server logs.');
toast({
type: 'error',
title: 'Domain Tag Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function deleteDomain(domainId) {
const response = await apiDelete(`${COLLECTIONS_API}/${domainId}`);
if (response.status !== 204) {
const message = await getErrorMessage(response, 'Failed to delete domain.');
toast({
type: 'error',
title: 'Delete Failed',
description: message,
time: 5000,
});
throw new Error(message);
}
return response;
}
export async function refreshDomain(domainId) {
let url = `${COLLECTIONS_API}/${domainId}/refresh`;
const response = await apiPost(url);
if (!response.ok) {
const message = await getErrorMessage(response, "Failed to refresh this domain's directory");
toast({
type: 'error',
title: 'Failed to refresh',
description: message,
time: 5000,
});
} else {
toast({
type: 'success',
title: 'Domain refresh started',
description: 'The domain directory is being refreshed',
time: 3000,
});
}
return response;
}
export async function getArchive(archiveId) {
const response = await apiGet(`${ARCHIVES_API}/${archiveId}`);
if (response.ok) {
@ -1519,4 +1650,27 @@ export async function postRestart() {
export async function postVideoFileFormat(video_file_format) {
const body = {video_file_format};
return await apiPost(`${API_URI}/videos/file_format`, body);
}
export async function checkUpgrade(force = false) {
const url = force ? `${API_URI}/upgrade/check?force=true` : `${API_URI}/upgrade/check`;
const response = await apiGet(url);
if (response.ok) {
return await response.json();
}
return null;
}
export async function triggerUpgrade() {
const response = await apiPost(`${API_URI}/upgrade/start`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to start upgrade. See server logs.');
toast({
type: 'error',
title: 'Upgrade Error',
description: message,
time: 5000,
});
}
return response;
}

View file

@ -9,10 +9,7 @@ import {
GridRow,
Image,
Input,
PlaceholderHeader,
PlaceholderLine,
TableCell,
TableRow
} from "semantic-ui-react";
import {
APIButton,
@ -35,19 +32,31 @@ import {
textEllipsis,
useTitle
} from "./Common";
import {deleteArchives, generateArchiveScreenshot, postDownload, tagFileGroup, untagFileGroup} from "../api";
import {Link, Route, Routes, useNavigate, useParams} from "react-router-dom";
import {deleteArchives, deleteDomain, generateArchiveScreenshot, getCollectionTagInfo, postDownload, refreshDomain, tagDomain, tagFileGroup, untagFileGroup} from "../api";
import {CollectionTagModal} from "./collections/CollectionTagModal";
import {Link, Route, Routes, useLocation, useNavigate, useParams} from "react-router-dom";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {useArchive, useDomains, useSearchArchives, useSearchOrder} from "../hooks/customHooks";
import {
useArchive,
useCollectionMetadata,
useDomain,
useDomains,
useOneQuery,
useSearchArchives,
useSearchOrder
} from "../hooks/customHooks";
import {FileCards, FileRowTagIcon, FilesView} from "./Files";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import _ from "lodash";
import {Media, ThemeContext} from "../contexts/contexts";
import {Button, Card, CardIcon, darkTheme, Header, Loader, Placeholder, Popup, Segment, Tab, TabPane} from "./Theme";
import {SortableTable} from "./SortableTable";
import {taggedImageLabel, TagsSelector} from "../Tags";
import {taggedImageLabel, TagsSelector, TagsContext} from "../Tags";
import {toast} from "react-semantic-toasts-2";
import {API_ARCHIVE_UPLOAD_URI, Downloaders} from "./Vars";
import {CollectionTable} from "./collections/CollectionTable";
import {CollectionEditForm} from "./collections/CollectionEditForm";
import {RecurringDownloadsTable} from "./admin/Downloads";
function archiveFileLink(path, directory = false) {
if (path) {
@ -350,66 +359,219 @@ export function ArchiveCard({file}) {
export function DomainsPage() {
useTitle('Archive Domains');
const [domains] = useDomains();
const [searchStr, setSearchStr] = useState('');
const [domains, total, metadata] = useDomains();
const [searchStr, setSearchStr] = useOneQuery('domain');
if (domains === null) {
// Request is pending.
// Header section matching ChannelsPage pattern
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<SearchInput
placeholder='Domain filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(domains) || domains.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
/>
</Grid.Column>
<Grid.Column textAlign='right'>
{/* No "New Domain" button - domains are auto-created */}
</Grid.Column>
</Grid.Row>
</Grid>
</div>;
// Empty state
if (domains && domains.length === 0) {
return <>
<Placeholder>
<PlaceholderHeader>
<PlaceholderLine/>
<PlaceholderLine/>
</PlaceholderHeader>
</Placeholder>
{header}
<Message>
<Message.Header>No domains yet. Archive some webpages!</Message.Header>
</Message>
</>;
} else if (domains === undefined) {
return <ErrorMessage>Could not fetch domains</ErrorMessage>
} else if (domains && domains.length === 0) {
return <Message>
<Message.Header>No domains yet.</Message.Header>
<Message.Content>Archive some webpages!</Message.Content>
</Message>;
}
let filteredDomains = domains;
if (searchStr) {
const re = new RegExp(_.escapeRegExp(searchStr), 'i');
filteredDomains = domains.filter(i => re.test(i['domain']));
// Error state
if (domains === undefined) {
return <>
{header}
<ErrorMessage>Could not fetch Domains</ErrorMessage>
</>;
}
const domainRow = ({domain, url_count, size}) => {
return <TableRow key={domain}>
<TableCell>
<Link to={`/archive?domain=${domain}`}>
{domain}
</Link>
</TableCell>
<TableCell>{url_count}</TableCell>
<TableCell>{humanFileSize(size)}</TableCell>
</TableRow>
}
const headers = [
{key: 'domain', text: 'Domain', sortBy: 'domain', width: 12},
{key: 'archives', text: 'Archives', sortBy: 'url_count', width: 2},
{key: 'Size', text: 'Size', sortBy: 'size', width: 2},
];
return <>
<Input
icon='search'
value={searchStr}
placeholder='Search...'
onChange={(e, {value}) => setSearchStr(value)}
{header}
<CollectionTable
collections={domains}
metadata={metadata}
searchStr={searchStr}
/>
<SortableTable
tableProps={{unstackable: true}}
data={filteredDomains}
rowFunc={(i, sortData) => domainRow(i)}
rowKey='domain'
tableHeaders={headers}
</>;
}
export function DomainEditPage() {
const {domainId} = useParams();
const navigate = useNavigate();
const {domain, form, fetchDomain} = useDomain(parseInt(domainId));
const {metadata} = useCollectionMetadata('domain');
// Modal state for tagging
const [tagEditModalOpen, setTagEditModalOpen] = useState(false);
// Filter out tag_name field - we use the modal button instead of inline selector
const filteredMetadata = React.useMemo(() => {
if (!metadata) return metadata;
return {
...metadata,
fields: metadata.fields.filter(field => field.key !== 'tag_name')
};
}, [metadata]);
useTitle(`Edit Domain: ${domain?.domain || '...'}`);
// Wrap form.onSubmit to add toast and refresh domain data
React.useEffect(() => {
if (form && form.onSubmit) {
const originalOnSubmit = form.onSubmit;
form.onSubmit = async () => {
try {
await originalOnSubmit();
toast({
type: 'success',
title: 'Domain Updated',
description: 'Domain was successfully updated',
time: 3000,
});
// Refresh domain data to show updated values
await fetchDomain();
} catch (e) {
console.error('Failed to update domain:', e);
throw e;
}
};
}
}, [form, fetchDomain]);
// Handler for tag modal save
const handleTagSave = async (tagName, directory) => {
try {
await tagDomain(parseInt(domainId), tagName, directory);
toast({
type: 'success',
title: 'Domain Tagged',
description: `Domain "${domain?.domain}" has been tagged with "${tagName}"`,
time: 3000,
});
} catch (e) {
console.error('Failed to tag domain', e);
} finally {
setTimeout(async () => {
await fetchDomain();
}, 500);
}
};
// Handler for fetching tag info
const handleGetTagInfo = async (tagName) => {
if (domain?.id) {
return await getCollectionTagInfo(domain.id, tagName);
}
return null;
};
const handleRefreshDomain = async (e) => {
if (e) {
e.preventDefault();
}
await refreshDomain(parseInt(domainId));
// Refresh domain data after completion
await fetchDomain();
};
if (!form.ready) {
return <Loader active>Loading domain...</Loader>;
}
// Handler for domain deletion
const handleDelete = async () => {
try {
let response = await deleteDomain(parseInt(domainId));
if (response.status === 204) {
navigate('/archive/domains');
}
} catch (e) {
console.error('Failed to delete domain', e);
}
};
const deleteButton = <APIButton
color='red'
size='small'
confirmContent='Are you sure you want to delete this domain? No archive files will be deleted, but archives will be orphaned.'
confirmButton='Delete'
confirmHeader='Delete Domain?'
onClick={handleDelete}
obeyWROLMode={true}
style={{marginTop: '1em'}}
>Delete</APIButton>;
const refreshButton = domain?.directory ? (
<APIButton
color='blue'
size='small'
onClick={handleRefreshDomain}
obeyWROLMode={true}
style={{marginTop: '1em'}}
>Refresh</APIButton>
) : null;
const tagButton = <Button
type="button"
size='small'
onClick={() => setTagEditModalOpen(true)}
color='green'
style={{marginTop: '1em'}}
>Tag</Button>;
const actionButtons = <>
{deleteButton}
{refreshButton}
{tagButton}
</>;
return <>
<BackButton/>
<CollectionEditForm
form={form}
metadata={filteredMetadata}
title={`Edit Domain: ${domain?.domain || '...'}`}
wrolModeContent='Domain editing is disabled while in WROL Mode.'
actionButtons={actionButtons}
appliedTagName={domain?.tag_name}
/>
{/* Tag Modal */}
<CollectionTagModal
open={tagEditModalOpen}
onClose={() => setTagEditModalOpen(false)}
currentTagName={domain?.tag_name}
originalDirectory={domain?.directory}
getTagInfo={handleGetTagInfo}
onSave={handleTagSave}
collectionName="Domain"
/>
{/* Downloads Segment */}
<Segment>
<Header as='h1'>Downloads</Header>
<RecurringDownloadsTable
downloads={domain?.downloads}
fetchDownloads={fetchDomain}
/>
</Segment>
</>;
}
@ -633,9 +795,21 @@ export function ArchiveRowCells({file}) {
}
export function ArchiveRoute() {
const location = useLocation();
const path = location.pathname;
const links = [
{text: 'Archives', to: '/archive', end: true},
{text: 'Domains', to: '/archive/domains'},
{
text: 'Archives',
to: '/archive',
end: true,
isActive: () => path === '/archive' || /^\/archive\/\d+$/.test(path)
},
{
text: 'Domains',
to: '/archive/domains',
isActive: () => path.startsWith('/archive/domain')
},
{text: 'Settings', to: '/archive/settings'},
];
return <PageContainer>
@ -643,6 +817,7 @@ export function ArchiveRoute() {
<Routes>
<Route path='/' element={<ArchivesPage/>}/>
<Route path='domains' element={<DomainsPage/>}/>
<Route path='domain/:domainId/edit' element={<DomainEditPage/>}/>
<Route path='settings' element={<ArchiveSettingsPage/>}/>
<Route path=':archiveId' element={<ArchivePage/>}/>
</Routes>

View file

@ -1,6 +1,7 @@
import React, {useState} from "react";
import {Grid, Input, StatisticLabel, StatisticValue, TableCell, TableRow,} from "semantic-ui-react";
import {Grid, StatisticLabel, StatisticValue, TableCell, TableRow,} from "semantic-ui-react";
import {createChannel, deleteChannel, refreshChannel, tagChannel, tagChannelInfo, updateChannel} from "../api";
import {CollectionTagModal} from "./collections/CollectionTagModal";
import {
APIButton,
BackButton,
@ -12,7 +13,6 @@ import {
secondsToFrequency,
secondsToFullDuration,
SimpleAccordion,
Toggle,
useTitle,
WROLModeMessage
} from "./Common";
@ -26,9 +26,7 @@ import {
Header,
Loader,
Modal,
ModalActions,
ModalContent,
ModalHeader,
Segment,
Statistic
} from "./Theme";
@ -36,9 +34,10 @@ import {Media, ThemeContext} from "../contexts/contexts";
import {SortableTable} from "./SortableTable";
import {toast} from "react-semantic-toasts-2";
import {RecurringDownloadsTable} from "./admin/Downloads";
import {TagsContext, TagsSelector} from "../Tags";
import {TagsContext} from "../Tags";
import {InputForm, ToggleForm} from "../hooks/useForm";
import {ChannelDownloadForm, DestinationForm, DownloadTagsSelector} from "./Download";
import {CollectionTable} from "./collections/CollectionTable";
function ChannelStatistics({statistics}) {
@ -86,20 +85,11 @@ export function ChannelPage({create, header}) {
const {SingleTag} = React.useContext(TagsContext);
const [tagEditModalOpen, setTagEditModalOpen] = useState(false);
const [newTagName, setNewTagName] = useState(null);
const [moveToTagDirectory, setMoveToTagDirectory] = useState(true);
const [newTagDirectory, setNewTagDirectory] = useState('');
const {channel, form, fetchChannel} = useChannel(channelId);
useTitle(_.isEmpty(channel) ? null : `${channel.name} Channel`);
React.useEffect(() => {
if (channel && channel.tag_name !== newTagName) {
setNewTagName(channel.tag_name);
}
}, [channel]);
if (!create && !form.ready) {
// Waiting for editing Channel to be fetched.
return <Loader active/>;
@ -223,10 +213,10 @@ export function ChannelPage({create, header}) {
setDownloadModalOpen(false);
}
const handleTagEditChannel = async () => {
// Handler for tag modal save
const handleTagSave = async (tagName, directory) => {
try {
await tagChannel(channelId, newTagName, moveToTagDirectory ? newTagDirectory : null);
setTagEditModalOpen(false);
await tagChannel(channelId, tagName, directory);
} catch (e) {
console.error('Failed to tag channel', e);
} finally {
@ -235,69 +225,25 @@ export function ChannelPage({create, header}) {
await fetchChannel();
}, 500);
}
}
};
const handleTagSelectMoveSuggestion = async (newTagName_) => {
setNewTagName(newTagName_);
try {
// Get suggested Tag directory for this Tag and Channel.
const videosDestination = await tagChannelInfo(channelId, newTagName_);
setNewTagDirectory(videosDestination);
} catch (e) {
console.error('Failed to tag channel', e);
}
}
// Handler for fetching tag info
const handleGetTagInfo = async (tagName) => {
return await tagChannelInfo(channelId, tagName);
};
let tagEditModal;
if (!create) {
// User is editing the Tag of the Channel.
tagEditModal = <Modal
tagEditModal = <CollectionTagModal
open={tagEditModalOpen}
onClose={() => setTagEditModalOpen(false)}
closeIcon
>
<ModalHeader>{channel.tag_name ? 'Modify Tag' : 'Add Tag'}</ModalHeader>
<ModalContent>
<Grid columns={1}>
<Grid.Row>
<Grid.Column>
<TagsSelector
limit={1}
selectedTagNames={newTagName ? [newTagName] : []}
onAdd={handleTagSelectMoveSuggestion}
onRemove={() => handleTagSelectMoveSuggestion(null)}
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<Toggle
label='Move to directory: '
checked={moveToTagDirectory}
onChange={setMoveToTagDirectory}
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<Input fluid
value={newTagDirectory}
onChange={(e, {value}) => setNewTagDirectory(value)}
disabled={!moveToTagDirectory}
/>
</Grid.Column>
</Grid.Row>
</Grid>
</ModalContent>
<ModalActions>
<Button onClick={() => setTagEditModalOpen(false)}>Cancel</Button>
<APIButton
color='violet'
onClick={handleTagEditChannel}
obeyWROLMode={true}
>{moveToTagDirectory ? 'Move' : 'Save'}</APIButton>
</ModalActions>
</Modal>;
currentTagName={channel.tag_name}
originalDirectory={channel.directory}
getTagInfo={handleGetTagInfo}
onSave={handleTagSave}
collectionName="Channel"
/>;
}
let channelUrlRow;
@ -502,72 +448,13 @@ export function ChannelNewPage(props) {
return <ChannelPage header='New Channel' {...props} create/>
}
function ChannelRow({channel}) {
const {SingleTag} = React.useContext(TagsContext);
const videosTo = `/videos/channel/${channel.id}/video`;
const {inverted} = React.useContext(ThemeContext);
const editTo = `/videos/channel/${channel.id}/edit`;
const buttonClass = `ui button secondary ${inverted}`;
return <TableRow>
<TableCell>
<Link to={videosTo}>{channel.name}</Link>
</TableCell>
<TableCell>
{channel.tag_name ? <SingleTag name={channel.tag_name}/> : null}
</TableCell>
<TableCell>
{channel.video_count}
</TableCell>
<TableCell>
{channel.url && channel.minimum_frequency ? secondsToFrequency(channel.minimum_frequency) : null}
</TableCell>
<TableCell>
{channel.size ? humanFileSize(channel.size) : null}
</TableCell>
<TableCell textAlign='right'>
<Link className={buttonClass} to={editTo}>Edit</Link>
</TableCell>
</TableRow>;
}
function MobileChannelRow({channel}) {
const {SingleTag} = React.useContext(TagsContext);
const editTo = `/videos/channel/${channel.id}/edit`;
const videosTo = `/videos/channel/${channel.id}/video`;
return <TableRow verticalAlign='top'>
<TableCell width={10} colSpan={2}>
<Link as='h3' to={videosTo}>
<h3>
{channel.name}
</h3>
{channel.tag_name ? <SingleTag name={channel.tag_name}/> : null}
</Link>
<p>
Videos: {channel.video_count}
</p>
</TableCell>
<TableCell width={6} colSpan={2} textAlign='right'>
<p>
<Link className="ui button secondary" to={editTo}>Edit</Link>
</p>
</TableCell>
</TableRow>;
}
export function ChannelsPage() {
useTitle('Channels');
const {channels} = useChannels();
const [channels, total, metadata] = useChannels();
const [searchStr, setSearchStr] = useOneQuery('name');
// Hides Channels with few videos.
const [hideSmall, setHideSmall] = React.useState(true);
const enoughChannelsToHideSmall = channels && channels.length > 20;
// Header section matching DomainsPage pattern
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
@ -591,25 +478,7 @@ export function ChannelsPage() {
</Grid>
</div>;
const headers = [
{key: 'name', text: 'Name', sortBy: [i => i['name'].toLowerCase()], width: 8},
{key: 'tag', text: 'Tag', sortBy: [i => i['tag_name'], i => i['name'].toLowerCase()], width: 2},
{key: 'video_count', text: 'Videos', sortBy: [i => i['video_count'], i => i['name'].toLowerCase()], width: 2},
{
key: 'download_frequency',
text: 'Download Frequency',
sortBy: [i => i['minimum_frequency'], i => i['name'].toLowerCase()],
width: 2
},
{key: 'size', text: 'Size', sortBy: [i => i['size'], i => i['name'].toLowerCase()], width: 2},
{key: 'manage', text: 'Manage', width: 2},
];
const mobileHeaders = [
{key: 'name', text: 'Name', sortBy: [i => i['name'].toLowerCase()]},
{key: 'video_count', text: 'Videos', sortBy: [i => i['video_count'], i => i['name'].toLowerCase()]},
{key: 'manage', text: 'Manage'},
];
// Empty state
if (channels && channels.length === 0) {
return <>
{header}
@ -617,65 +486,23 @@ export function ChannelsPage() {
<Message.Header>No channels exist yet!</Message.Header>
<Message.Content><Link to='/videos/channel/new'>Create one.</Link></Message.Content>
</Message>
</>
} else if (channels === undefined) {
</>;
}
// Error state
if (channels === undefined) {
return <>
{header}
<ErrorMessage>Could not fetch Channels</ErrorMessage>
</>
}
let filteredChannels = channels;
if (searchStr && Array.isArray(channels)) {
const re = new RegExp(_.escapeRegExp(searchStr), 'i');
filteredChannels = channels.filter(i => re.test(i['name']));
} else if (channels && hideSmall && enoughChannelsToHideSmall) {
// Get the top 80% of Channels.
let index80 = Math.floor(channels.length * 0.8);
const sortedChannels = channels.sort((a, b) => b.video_count - a.video_count);
let percentile = sortedChannels[index80].video_count;
filteredChannels = channels.filter(i => {
return i.video_count > percentile || i.name.toLowerCase() === 'wrolpi'
});
if (filteredChannels.length < 21) {
// Filtering hid too many Channels, show them all.
filteredChannels = channels;
}
</>;
}
return <>
{header}
<Media at='mobile'>
<SortableTable
tableProps={{striped: true, size: 'small', unstackable: true}}
data={filteredChannels}
tableHeaders={mobileHeaders}
defaultSortColumn='name'
rowKey='id'
rowFunc={(i, sortData) => <MobileChannelRow key={i.id} channel={i} sortData={sortData}/>}
/>
</Media>
<Media greaterThanOrEqual='tablet'>
<SortableTable
tableProps={{striped: true, size: 'large', unstackable: true, compact: true}}
data={filteredChannels}
tableHeaders={headers}
defaultSortColumn='name'
rowKey='id'
rowFunc={(i, sortData) => <ChannelRow key={i.id} channel={i} sortData={sortData}/>}
/>
</Media>
<Grid textAlign='center'>
<Grid.Row>
<Grid.Column>
<Button
style={{marginTop: '1em'}}
onClick={() => setHideSmall(!hideSmall)}
size='big'
disabled={!enoughChannelsToHideSmall}
>Show {hideSmall ? 'More' : 'Less'}</Button>
</Grid.Column>
</Grid.Row>
</Grid>
</>
<CollectionTable
collections={channels}
metadata={metadata}
searchStr={searchStr}
/>
</>;
}

View file

@ -303,6 +303,21 @@ export function secondsToTimestamp(seconds) {
}
export function humanFileSize(bytes, dp = 1) {
// Handle null/undefined
if (bytes == null) {
return '-';
}
// Convert string to number if needed
if (typeof bytes === 'string') {
bytes = parseFloat(bytes);
}
// Handle invalid numbers
if (isNaN(bytes) || !isFinite(bytes)) {
return '-';
}
const thresh = 1024;
if (Math.abs(bytes) < thresh) {
@ -318,7 +333,6 @@ export function humanFileSize(bytes, dp = 1) {
++u;
} while (Math.round(Math.abs(bytes) * r) / r >= thresh && u < units.length - 1);
return bytes.toFixed(dp) + ' ' + units[u];
}
@ -596,10 +610,13 @@ export function TabLinks({links}) {
return <Menu tabular>
{links.map((link) => <NavLink
to={getTo(link.to)}
className='item'
style={{padding: '1em'}}
key={link.to}
end={link.end === true ? true : null}
className={({isActive}) => {
const active = link.isActive ? link.isActive() : isActive;
return active ? 'item active' : 'item';
}}
>
{link.text}
</NavLink>)}
@ -1224,10 +1241,18 @@ export function DirectorySearch({onSelect, value, disabled, required, ...props})
}
}
const handleBlur = (e) => {
// When user leaves the field, commit the typed value to the form
if (onSelect && directoryName !== value) {
onSelect(directoryName);
}
}
return <Search category
placeholder='Search directory names...'
onSearchChange={handleSearchChange}
onResultSelect={handleResultSelect}
onBlur={handleBlur}
loading={loading}
value={directoryName}
results={results}

View file

@ -0,0 +1,392 @@
import React from 'react';
import {render, screen, waitFor, act} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {DirectorySearch} from './Common';
// Mock debounce to make tests run faster
jest.mock('lodash/debounce', () => jest.fn(fn => {
fn.cancel = jest.fn();
return fn;
}));
// Mock useSearchDirectories hook to avoid async state updates that cause act() warnings
const mockSetDirectoryName = jest.fn();
let mockHookState = {
directoryName: '',
directories: [],
channelDirectories: [],
domainDirectories: [],
isDir: false,
loading: false,
};
jest.mock('../hooks/customHooks', () => ({
...jest.requireActual('../hooks/customHooks'),
useSearchDirectories: (value) => {
// Return current mock state - tests control state via setMockHookState
return {
...mockHookState,
setDirectoryName: (newValue) => {
mockHookState.directoryName = newValue;
mockSetDirectoryName(newValue);
},
};
},
}));
describe('DirectorySearch', () => {
const mockOnSelect = jest.fn();
const mockSearchResults = {
directories: [
{path: 'videos/nature'},
{path: 'videos/tech'}
],
channelDirectories: [
{path: 'videos/channels/news', name: 'News Channel'}
],
domainDirectories: [
{path: 'archive/example.com', domain: 'example.com'}
],
};
// Helper to reset mock hook state with specific values
const setMockHookState = (overrides = {}) => {
mockHookState = {
directoryName: '',
directories: [],
channelDirectories: [],
domainDirectories: [],
isDir: false,
loading: false,
...overrides,
};
};
beforeEach(() => {
jest.clearAllMocks();
// Reset mock hook state with default search results
setMockHookState({
...mockSearchResults,
isDir: false,
});
});
describe('Rendering', () => {
it('renders with placeholder text', () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toBeInTheDocument();
});
it('shows initial value when provided', () => {
setMockHookState({
...mockSearchResults,
directoryName: 'videos/test',
});
render(<DirectorySearch onSelect={mockOnSelect} value="videos/test" />);
const input = screen.getByDisplayValue('videos/test');
expect(input).toBeInTheDocument();
});
it('applies disabled state correctly', () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" disabled />);
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toBeDisabled();
});
it('displays with required indicator', () => {
const {container} = render(
<DirectorySearch onSelect={mockOnSelect} value="" required />
);
// Semantic UI doesn't add required attribute to Search input,
// but we verify the prop is passed
expect(container.querySelector('.ui.search')).toBeInTheDocument();
});
});
describe('Search Functionality', () => {
it('triggers setDirectoryName on value change', async () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.type(input, 'videos');
// Verify setDirectoryName was called (via mocked hook)
expect(mockSetDirectoryName).toHaveBeenCalled();
// Debounce is mocked, so each character triggers a call
// Verify it was called 6 times (one per character in "videos")
expect(mockSetDirectoryName.mock.calls.length).toBe(6);
});
it('shows loading indicator when loading state is true', () => {
setMockHookState({
...mockSearchResults,
loading: true,
});
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const searchContainer = screen.getByPlaceholderText(/search directory names/i)
.closest('.ui.search');
expect(searchContainer).toHaveClass('loading');
});
it('hides loading indicator when loading state is false', () => {
setMockHookState({
...mockSearchResults,
loading: false,
});
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const searchContainer = screen.getByPlaceholderText(/search directory names/i)
.closest('.ui.search');
expect(searchContainer).not.toHaveClass('loading');
});
it('displays categorized results', async () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
// Click to open dropdown
await userEvent.click(input);
await waitFor(() => {
// Should show category names
expect(screen.getAllByText(/Directories/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Channels/i).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Domains/i).length).toBeGreaterThan(0);
});
});
it('shows "New Directory" when path doesn\'t exist (isDir=false)', async () => {
setMockHookState({
directories: [],
channelDirectories: [],
domainDirectories: [],
isDir: false,
directoryName: 'new/path',
});
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.click(input);
await waitFor(() => {
expect(screen.getByText(/New Directory/i)).toBeInTheDocument();
});
});
it('hides "New Directory" when path exists (isDir=true)', async () => {
setMockHookState({
directories: [{path: 'videos/nature/wildlife'}],
channelDirectories: [],
domainDirectories: [],
isDir: true,
directoryName: 'videos/nature',
});
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.click(input);
// "New Directory" should not appear when is_dir=true
expect(screen.queryByText(/New Directory/i)).not.toBeInTheDocument();
});
it('debounces rapid typing (verifies setDirectoryName is called)', async () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
// Type rapidly
await userEvent.type(input, 'abc', {delay: 10});
// Verify setDirectoryName was called
expect(mockSetDirectoryName).toHaveBeenCalled();
});
});
describe('User Interactions', () => {
it('calls onSelect when result is clicked', async () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.click(input);
await waitFor(() => {
expect(screen.getByText('videos/nature')).toBeInTheDocument();
});
// Click on a result
const result = screen.getByText('videos/nature');
await userEvent.click(result);
expect(mockOnSelect).toHaveBeenCalledWith('videos/nature');
});
it('commits typed value on blur when directoryName differs from value', async () => {
// Set up mock state where directoryName differs from the prop value
// This simulates what happens after user types in the input
setMockHookState({
...mockSearchResults,
directoryName: 'typed/path', // User has typed this
});
// Render with empty value prop (different from directoryName)
const {container} = render(<DirectorySearch onSelect={mockOnSelect} value="" />);
// Find the Search component container and trigger blur on it
const searchComponent = container.querySelector('.ui.search');
// Blur the Search component - should trigger onBlur which calls onSelect
await act(async () => {
// Use fireEvent.blur which better simulates the Semantic UI Search blur behavior
const {fireEvent} = require('@testing-library/react');
fireEvent.blur(searchComponent);
});
// Should call onSelect with directoryName from hook state
expect(mockOnSelect).toHaveBeenCalledWith('typed/path');
});
it('does not call onSelect on blur if value unchanged', async () => {
setMockHookState({
...mockSearchResults,
directoryName: 'existing/path',
});
render(<DirectorySearch onSelect={mockOnSelect} value="existing/path" />);
const input = screen.getByDisplayValue('existing/path');
// Blur without changing value
await act(async () => {
input.blur();
});
// Should not call onSelect since value didn't change
expect(mockOnSelect).not.toHaveBeenCalled();
});
it('disabled state prevents interactions', () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" disabled />);
const input = screen.getByPlaceholderText(/search directory names/i);
// Input should be disabled
expect(input).toBeDisabled();
});
it('handles rapid selection changes', async () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.click(input);
await waitFor(() => {
expect(screen.getByText('videos/nature')).toBeInTheDocument();
});
// Click multiple results in succession
await userEvent.click(screen.getByText('videos/nature'));
await userEvent.click(screen.getByText('videos/tech'));
// Should call onSelect for each selection
expect(mockOnSelect).toHaveBeenCalledWith('videos/nature');
expect(mockOnSelect).toHaveBeenCalledWith('videos/tech');
});
});
describe('Hook Integration', () => {
it('calls setDirectoryName from hook on search change', async () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.type(input, 'archive');
expect(mockSetDirectoryName).toHaveBeenCalled();
});
it('displays results from hook state', async () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
await userEvent.click(input);
await waitFor(() => {
// Should display results from mock hook state
expect(screen.getByText('videos/nature')).toBeInTheDocument();
expect(screen.getByText('videos/tech')).toBeInTheDocument();
expect(screen.getByText('News Channel')).toBeInTheDocument();
});
});
});
describe('Edge Cases', () => {
it('handles null/undefined initial value', () => {
setMockHookState({
...mockSearchResults,
directoryName: '',
});
render(<DirectorySearch onSelect={mockOnSelect} value={null} />);
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toHaveValue('');
});
it('clears results display when empty', async () => {
setMockHookState({
directories: [],
channelDirectories: [],
domainDirectories: [],
isDir: false,
directoryName: '',
});
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
expect(input).toHaveValue('');
});
it('maintains value when component remounts', () => {
setMockHookState({
...mockSearchResults,
directoryName: 'videos/test',
});
const {rerender} = render(
<DirectorySearch onSelect={mockOnSelect} value="videos/test" />
);
expect(screen.getByDisplayValue('videos/test')).toBeInTheDocument();
// Remount with same value
rerender(<DirectorySearch onSelect={mockOnSelect} value="videos/test" />);
expect(screen.getByDisplayValue('videos/test')).toBeInTheDocument();
});
it('handles special characters in path', async () => {
render(<DirectorySearch onSelect={mockOnSelect} value="" />);
const input = screen.getByPlaceholderText(/search directory names/i);
// Type path with special characters
const specialPath = 'videos/test-folder_2024/v1.0';
await userEvent.type(input, specialPath);
expect(mockSetDirectoryName).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,446 @@
import React from 'react';
import {render, renderInDarkMode, screen, waitFor, createTestForm} from '../test-utils';
import userEvent from '@testing-library/user-event';
import {DomainEditPage} from './Archive';
import {createMockDomain, createMockMetadata} from '../test-utils';
// Mock useParams to return domain ID
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: () => ({domainId: '1'}),
useNavigate: () => jest.fn(),
}));
// Mock the useDomain hook
const mockUseDomain = jest.fn();
const mockUseCollectionMetadata = jest.fn();
jest.mock('../hooks/customHooks', () => ({
...jest.requireActual('../hooks/customHooks'),
useDomain: (...args) => mockUseDomain(...args),
useCollectionMetadata: (...args) => mockUseCollectionMetadata(...args),
}));
// Mock useTitle
jest.mock('./Common', () => ({
...jest.requireActual('./Common'),
useTitle: jest.fn(),
}));
// Mock CollectionEditForm
jest.mock('./collections/CollectionEditForm', () => ({
CollectionEditForm: ({form, metadata, title, actionButtons}) => (
<div data-testid="collection-edit-form">
{title && <h1>{title}</h1>}
{form?.loading && <div data-testid="loading-indicator">Loading...</div>}
{form?.formData && <div data-testid="collection-data">Collection data loaded</div>}
{metadata && <div data-testid="metadata-present">Metadata loaded</div>}
{actionButtons && <div data-testid="action-buttons">{actionButtons}</div>}
</div>
),
}));
// Mock CollectionTagModal
jest.mock('./collections/CollectionTagModal', () => ({
CollectionTagModal: ({open, onClose, currentTagName, originalDirectory, getTagInfo, onSave, collectionName}) => {
if (!open) return null;
return (
<div data-testid="collection-tag-modal">
<div data-testid="modal-header">{currentTagName ? 'Modify Tag' : 'Add Tag'}</div>
<input type="text" data-testid="directory-input" defaultValue={originalDirectory || ''} />
<button data-testid="cancel-button" onClick={onClose}>Cancel</button>
<button data-testid="save-button" onClick={() => onSave && onSave(currentTagName, originalDirectory)}>Save</button>
</div>
);
},
}));
// Mock API functions
const mockGetCollectionTagInfo = jest.fn();
jest.mock('../api', () => ({
...jest.requireActual('../api'),
getCollectionTagInfo: (...args) => mockGetCollectionTagInfo(...args),
tagDomain: jest.fn(),
}));
describe('DomainEditPage', () => {
const mockMetadata = createMockMetadata();
beforeEach(() => {
jest.clearAllMocks();
mockUseCollectionMetadata.mockReturnValue({metadata: mockMetadata});
});
describe('Loading States', () => {
it('handles loading state while fetching domain', async () => {
// Start with loading state (no domain yet)
const form = createTestForm({}, {
overrides: {ready: false, loading: false}
});
mockUseDomain.mockReturnValue({
domain: null,
form,
fetchDomain: jest.fn(),
});
render(<DomainEditPage />);
// Should show Semantic UI Loader with text
expect(screen.getByText(/loading domain/i)).toBeInTheDocument();
// Form should not be visible during initial load
expect(screen.queryByTestId('collection-edit-form')).not.toBeInTheDocument();
});
it('shows form when domain is loaded', () => {
const mockDomain = createMockDomain({
domain: 'test.com',
});
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: false}
});
mockUseDomain.mockReturnValue({
domain: mockDomain,
form,
fetchDomain: jest.fn(),
});
render(<DomainEditPage />);
// Should NOT show loading message
expect(screen.queryByText(/loading domain/i)).not.toBeInTheDocument();
// Should show domain name (may appear multiple times in header and form)
expect(screen.getAllByText(/test\.com/i).length).toBeGreaterThan(0);
});
it('passes loading state to form during submission', () => {
const mockDomain = createMockDomain({
domain: 'example.com',
});
// Domain is loaded but form is submitting
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: true}
});
mockUseDomain.mockReturnValue({
domain: mockDomain,
form,
fetchDomain: jest.fn(),
});
render(<DomainEditPage />);
// Should show loading indicator in form (from mocked component)
// Multiple indicators due to Fresnel rendering for mobile and tablet+
expect(screen.getAllByTestId('loading-indicator').length).toBeGreaterThan(0);
// Should also show the domain name
expect(screen.getAllByText(/example\.com/i).length).toBeGreaterThan(0);
});
});
describe('Error States', () => {
it('shows loader when form is not ready (fetch fails)', () => {
// When form.ready is false (e.g., fetch failed), show loader
const form = createTestForm({}, {
overrides: {ready: false, loading: false, error: new Error('Domain not found')}
});
mockUseDomain.mockReturnValue({
domain: null,
form,
fetchDomain: jest.fn(),
});
render(<DomainEditPage />);
// Should show loading screen when not ready
expect(screen.getByText(/loading domain/i)).toBeInTheDocument();
// Form should not be rendered
expect(screen.queryByTestId('collection-edit-form')).not.toBeInTheDocument();
});
it('shows form when ready even if there was a submission error', () => {
// Form is ready (domain loaded) but submission may have failed
const mockDomain = createMockDomain({
domain: 'example.com',
});
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: false, error: new Error('Update failed')}
});
mockUseDomain.mockReturnValue({
domain: mockDomain,
form,
fetchDomain: jest.fn(),
});
render(<DomainEditPage />);
// Should show domain name (form is rendered)
// Multiple occurrences due to Fresnel rendering for mobile and tablet+
expect(screen.getAllByText(/example\.com/i).length).toBeGreaterThan(0);
// Should NOT show the initial loading screen
expect(screen.queryByText(/loading domain/i)).not.toBeInTheDocument();
});
});
describe('Page Title', () => {
it('sets page title with domain name', () => {
const {useTitle} = require('./Common');
const mockDomain = createMockDomain({
domain: 'example.com',
});
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: false}
});
mockUseDomain.mockReturnValue({
domain: mockDomain,
form,
fetchDomain: jest.fn(),
});
render(<DomainEditPage />);
// useTitle should be called with domain name
expect(useTitle).toHaveBeenCalledWith('Edit Domain: example.com');
});
it('sets page title with placeholder while loading', () => {
const {useTitle} = require('./Common');
const form = createTestForm({}, {
overrides: {ready: false, loading: false}
});
mockUseDomain.mockReturnValue({
domain: null,
form,
fetchDomain: jest.fn(),
});
render(<DomainEditPage />);
// useTitle should be called with placeholder
expect(useTitle).toHaveBeenCalledWith('Edit Domain: ...');
});
});
describe('Theme Integration', () => {
it('passes theme context to CollectionEditForm in dark mode', () => {
const mockDomain = createMockDomain({
domain: 'example.com',
});
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: false}
});
mockUseDomain.mockReturnValue({
domain: mockDomain,
form,
fetchDomain: jest.fn(),
});
// Render in dark mode
renderInDarkMode(<DomainEditPage />);
// CollectionEditForm should be rendered
expect(screen.getByTestId('collection-edit-form')).toBeInTheDocument();
// The title should include the domain name
expect(screen.getAllByText(/example\.com/i).length).toBeGreaterThan(0);
});
it('renders properly in light mode', () => {
const mockDomain = createMockDomain({
domain: 'test.com',
});
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: false}
});
mockUseDomain.mockReturnValue({
domain: mockDomain,
form,
fetchDomain: jest.fn(),
});
// Default render (light mode)
render(<DomainEditPage />);
// CollectionEditForm should be rendered
expect(screen.getByTestId('collection-edit-form')).toBeInTheDocument();
// The title should include the domain name
expect(screen.getAllByText(/test\.com/i).length).toBeGreaterThan(0);
});
});
describe('Tag Modal and Directory Suggestions', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetCollectionTagInfo.mockClear();
});
it('suggests directory when tag is selected', async () => {
const mockDomain = createMockDomain({
domain: 'example.com',
id: 1,
});
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: false}
});
mockUseDomain.mockReturnValue({
domain: mockDomain,
form,
fetchDomain: jest.fn(),
});
// Mock successful tag info response
mockGetCollectionTagInfo.mockResolvedValue({
suggested_directory: 'archive/WROL/example.com',
conflict: false,
conflict_message: null,
});
render(<DomainEditPage />);
// Click the Tag button to open modal
const tagButton = screen.getByText('Tag');
await userEvent.click(tagButton);
// Wait for modal to open
await waitFor(() => {
expect(screen.getByText(/Modify Tag|Add Tag/i)).toBeInTheDocument();
});
// Simulate selecting a tag (this would normally be done by TagsSelector)
// Since TagsSelector is a real component, we need to wait for the API call
// We'll verify the API was called when a tag would be selected
// This test validates the structure is in place
});
it('displays conflict warning in modal when directory conflict exists', async () => {
const mockDomain = createMockDomain({
domain: 'example.com',
id: 1,
});
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: false}
});
mockUseDomain.mockReturnValue({
domain: mockDomain,
form,
fetchDomain: jest.fn(),
});
// Mock tag info response with conflict
mockGetCollectionTagInfo.mockResolvedValue({
suggested_directory: 'archive/WROL/example.com',
conflict: true,
conflict_message: "A domain collection 'other.com' already uses this directory. Choose a different tag or directory.",
});
render(<DomainEditPage />);
// Open the modal
const tagButton = screen.getByText('Tag');
await userEvent.click(tagButton);
// The modal should be present
await waitFor(() => {
expect(screen.getByTestId('collection-tag-modal')).toBeInTheDocument();
});
// Verify the modal structure includes the necessary inputs
expect(screen.getByTestId('directory-input')).toBeInTheDocument();
});
it('clears conflict message when tag is changed', async () => {
const mockDomain = createMockDomain({
domain: 'example.com',
id: 1,
});
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: false}
});
mockUseDomain.mockReturnValue({
domain: mockDomain,
form,
fetchDomain: jest.fn(),
});
render(<DomainEditPage />);
// Open modal
const tagButton = screen.getByText('Tag');
await userEvent.click(tagButton);
await waitFor(() => {
expect(screen.getByText(/Modify Tag|Add Tag/i)).toBeInTheDocument();
});
// Modal should be open and ready for tag selection
// The actual tag selection and conflict clearing would be tested in integration tests
// This validates the structure is present
});
it('populates directory input with suggested directory', async () => {
const mockDomain = createMockDomain({
domain: 'example.com',
id: 1,
directory: 'archive/example.com',
});
const form = createTestForm(mockDomain, {
overrides: {ready: true, loading: false}
});
mockUseDomain.mockReturnValue({
domain: mockDomain,
form,
fetchDomain: jest.fn(),
});
mockGetCollectionTagInfo.mockResolvedValue({
suggested_directory: 'archive/WROL/example.com',
conflict: false,
conflict_message: null,
});
render(<DomainEditPage />);
// Open modal
const tagButton = screen.getByText('Tag');
await userEvent.click(tagButton);
// Wait for modal to open
await waitFor(() => {
expect(screen.getByTestId('collection-tag-modal')).toBeInTheDocument();
});
// Verify directory input field exists with original directory value
const directoryInput = screen.getByTestId('directory-input');
expect(directoryInput).toBeInTheDocument();
expect(directoryInput).toHaveValue('archive/example.com');
});
});
});

View file

@ -0,0 +1,345 @@
import React from 'react';
import {render, screen, createTestForm} from '../test-utils';
import {CollectionEditForm} from './collections/CollectionEditForm';
import {DomainsPage} from './Archive';
import {createMockDomain, createMockMetadata, createMockDomains} from '../test-utils';
// Mock the TagsSelector component and TagsContext
jest.mock('../Tags', () => ({
TagsSelector: ({selectedTagNames, onChange, disabled}) => (
<div data-testid="tags-selector" data-disabled={disabled}>
<input
data-testid="tags-input"
value={selectedTagNames?.join(',') || ''}
onChange={(e) => onChange(e.target.value ? [e.target.value] : [])}
disabled={disabled}
/>
</div>
),
TagsContext: {
_currentValue: {
SingleTag: ({name}) => <span data-testid="applied-tag">{name}</span>
}
},
}));
// Mock the DirectorySearch and DestinationForm components
jest.mock('./Common', () => ({
...jest.requireActual('./Common'),
DirectorySearch: ({value, onSelect, placeholder}) => (
<input
data-testid="directory-search"
value={value || ''}
onChange={(e) => onSelect(e.target.value)}
placeholder={placeholder}
/>
),
SearchInput: ({placeholder, searchStr, onChange, disabled}) => (
<input
data-testid="search-input"
placeholder={placeholder}
value={searchStr || ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
),
ErrorMessage: ({children}) => <div data-testid="error-message">{children}</div>,
useTitle: jest.fn(),
}));
// Mock DestinationForm (used for directory field)
jest.mock('./Download', () => ({
DestinationForm: ({form, label, name}) => (
<div data-testid="directory-search">
<label>{label}</label>
<input
value={form.formData[name] || ''}
onChange={(e) => form.setValue(name, e.target.value)}
/>
</div>
),
}));
// Mock hooks for DomainsPage tests
const mockUseDomains = jest.fn();
const mockUseOneQuery = jest.fn(() => ['', jest.fn()]);
jest.mock('../hooks/customHooks', () => ({
...jest.requireActual('../hooks/customHooks'),
useDomains: (...args) => mockUseDomains(...args),
useOneQuery: (...args) => mockUseOneQuery(...args),
}));
// Mock CollectionTable for DomainsPage tests
jest.mock('./collections/CollectionTable', () => ({
CollectionTable: ({collections}) => (
<div data-testid="collection-table">
<table>
<tbody>
{collections?.map((domain) => (
<tr key={domain.id} data-testid={`domain-row-${domain.id}`}>
<td data-testid={`domain-name-${domain.id}`}>{domain.domain}</td>
<td data-testid={`domain-tag-${domain.id}`}>
{domain.tag_name || 'No tag'}
</td>
</tr>
))}
</tbody>
</table>
</div>
),
}));
describe('Domain Tagging Logic', () => {
const mockMetadata = createMockMetadata();
describe('Tag Field Dependency on Directory', () => {
it('prevents tagging domain without directory', () => {
const domainWithoutDirectory = createMockDomain({
directory: '',
tag_name: null,
can_be_tagged: false
});
const form = createTestForm(domainWithoutDirectory);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Should show dependency warning
expect(screen.getByText(/set a directory to enable tagging/i)).toBeInTheDocument();
// Tag selector should be disabled
const tagSelector = screen.getByTestId('tags-selector');
expect(tagSelector).toHaveAttribute('data-disabled', 'true');
const tagInput = screen.getByTestId('tags-input');
expect(tagInput).toBeDisabled();
});
it('enables tagging when directory is set', () => {
const domainWithDirectory = createMockDomain({
directory: 'archive/example.com',
tag_name: null,
can_be_tagged: true
});
const form = createTestForm(domainWithDirectory);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Dependency warning should not be shown
expect(screen.queryByText(/set a directory to enable tagging/i)).not.toBeInTheDocument();
// Tag selector should be enabled
const tagSelector = screen.getByTestId('tags-selector');
expect(tagSelector).toHaveAttribute('data-disabled', 'false');
const tagInput = screen.getByTestId('tags-input');
expect(tagInput).not.toBeDisabled();
});
it('shows warning when directory is set (enabling tagging)', () => {
const domainWithDirectory = createMockDomain({
directory: 'archive/example.com',
tag_name: null,
can_be_tagged: true
});
const form = createTestForm(domainWithDirectory);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Tag selector should be available (not disabled)
const tagSelector = screen.getByTestId('tags-selector');
expect(tagSelector).toHaveAttribute('data-disabled', 'false');
// Dependency message should not appear
expect(screen.queryByText(/set a directory to enable tagging/i)).not.toBeInTheDocument();
});
});
describe('Tag Display in Domains List', () => {
const {useTitle} = require('./Common');
beforeEach(() => {
// Reset mocks
mockUseDomains.mockReset();
mockUseOneQuery.mockReset();
useTitle.mockReset();
// Re-setup default mocks
useTitle.mockImplementation(() => {});
mockUseOneQuery.mockReturnValue(['', jest.fn()]);
});
it('displays tag in domains list after tagging', () => {
const mockDomains = [
createMockDomain({
id: 1,
domain: 'example.com',
tag_name: 'News', // Tagged domain
directory: 'archive/example.com',
can_be_tagged: true
}),
createMockDomain({
id: 2,
domain: 'test.org',
tag_name: null, // Untagged domain
directory: '',
can_be_tagged: false
}),
];
mockUseDomains.mockReturnValue([mockDomains, 2, mockMetadata]);
render(<DomainsPage />);
// Tagged domain should display its tag
expect(screen.getByTestId('domain-tag-1')).toHaveTextContent('News');
// Untagged domain should show "No tag"
expect(screen.getByTestId('domain-tag-2')).toHaveTextContent('No tag');
});
it('displays multiple tagged domains correctly', () => {
const mockDomains = [
createMockDomain({
id: 1,
domain: 'news.com',
tag_name: 'News',
directory: 'archive/news.com',
can_be_tagged: true
}),
createMockDomain({
id: 2,
domain: 'tech.com',
tag_name: 'Tech',
directory: 'archive/tech.com',
can_be_tagged: true
}),
createMockDomain({
id: 3,
domain: 'science.com',
tag_name: 'Science',
directory: 'archive/science.com',
can_be_tagged: true
}),
];
mockUseDomains.mockReturnValue([mockDomains, 3, mockMetadata]);
render(<DomainsPage />);
// All domains should display their respective tags
expect(screen.getByTestId('domain-tag-1')).toHaveTextContent('News');
expect(screen.getByTestId('domain-tag-2')).toHaveTextContent('Tech');
expect(screen.getByTestId('domain-tag-3')).toHaveTextContent('Science');
});
});
describe('Tag Warning Messages', () => {
it('shows "tagging will move files" warning when tag is set', () => {
const domainWithTag = createMockDomain({
directory: 'archive/example.com',
tag_name: 'News',
can_be_tagged: true
});
const form = createTestForm(domainWithTag);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Should show move warning when tag is present
expect(screen.getByText(/tagging will move files/i)).toBeInTheDocument();
});
it('does not show move warning when no tag is set', () => {
const domainWithoutTag = createMockDomain({
directory: 'archive/example.com',
tag_name: null,
can_be_tagged: true
});
const form = createTestForm(domainWithoutTag);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Should not show move warning when tag is absent
expect(screen.queryByText(/tagging will move files/i)).not.toBeInTheDocument();
});
});
describe('Tag Clearing Submission Bug', () => {
it('should send empty string (not null) when clearing tag', async () => {
// Create a domain with a tag
const domainWithTag = createMockDomain({
id: 1,
domain: 'example.com',
directory: 'archive/example.com',
tag_name: 'News',
can_be_tagged: true
});
// Create form with the domain data
const form = createTestForm(domainWithTag);
// Simulate clearing the tag
form.setValue('tag_name', null);
// Verify form has null
expect(form.formData.tag_name).toBe(null);
// Mock updateDomain to track what it's called with
const mockUpdateDomain = jest.fn().mockResolvedValue({ok: true});
// Mock onSubmit to call updateDomain like useDomain does (with fix)
form.onSubmit = jest.fn(async () => {
const body = {
directory: form.formData.directory,
description: form.formData.description,
// FIX: Convert null to empty string - backend expects "" to clear tag
tag_name: form.formData.tag_name === null ? '' : form.formData.tag_name,
};
return await mockUpdateDomain(1, body);
});
// Submit the form
await form.onSubmit();
// Verify updateDomain was called
expect(mockUpdateDomain).toHaveBeenCalledTimes(1);
// Verify tag_name is correctly converted from null to "" for the API
expect(mockUpdateDomain).toHaveBeenCalledWith(1, {
directory: 'archive/example.com',
description: '',
tag_name: '', // Empty string clears the tag (null is converted)
});
});
});
});

View file

@ -0,0 +1,236 @@
import React from 'react';
import {render, screen, waitFor} from '../test-utils';
import {DomainsPage} from './Archive';
import {createMockDomains, createMockMetadata} from '../test-utils';
// Mock the custom hooks
jest.mock('../hooks/customHooks', () => ({
...jest.requireActual('../hooks/customHooks'),
useDomains: jest.fn(),
useOneQuery: jest.fn(),
}));
// Mock CollectionTable component
jest.mock('./collections/CollectionTable', () => ({
CollectionTable: ({collections, metadata, searchStr}) => (
<div data-testid="collection-table">
<div data-testid="collection-count">{collections?.length || 0}</div>
<div data-testid="search-filter">{searchStr}</div>
{collections?.map((domain) => (
<div key={domain.id} data-testid={`domain-${domain.id}`}>
<span data-testid={`domain-name-${domain.id}`}>{domain.domain}</span>
<button className="ui mini primary button" data-testid={`edit-button-${domain.id}`}>
Edit
</button>
</div>
))}
</div>
),
}));
// Mock SearchInput component and useTitle
jest.mock('./Common', () => ({
...jest.requireActual('./Common'),
SearchInput: ({placeholder, searchStr, onChange, disabled}) => (
<input
data-testid="search-input"
placeholder={placeholder}
value={searchStr || ''}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
),
ErrorMessage: ({children}) => <div data-testid="error-message">{children}</div>,
useTitle: jest.fn(),
}));
describe('DomainsPage', () => {
const {useDomains, useOneQuery} = require('../hooks/customHooks');
const {useTitle} = require('./Common');
const mockMetadata = createMockMetadata();
beforeEach(() => {
// Reset mocks before each test
jest.clearAllMocks();
// Default mock implementations
useTitle.mockImplementation(() => {});
useOneQuery.mockReturnValue(['', jest.fn()]);
});
describe('Page Rendering', () => {
it('displays domains page without errors', () => {
const mockDomains = createMockDomains(3);
useDomains.mockReturnValue([mockDomains, 3, mockMetadata]);
render(<DomainsPage />);
// Page should render without crashing
expect(screen.getByTestId('search-input')).toBeInTheDocument();
expect(screen.getByTestId('collection-table')).toBeInTheDocument();
});
it('renders CollectionTable component', () => {
const mockDomains = createMockDomains(2);
useDomains.mockReturnValue([mockDomains, 2, mockMetadata]);
render(<DomainsPage />);
expect(screen.getByTestId('collection-table')).toBeInTheDocument();
});
it('shows search input', () => {
const mockDomains = createMockDomains(1);
useDomains.mockReturnValue([mockDomains, 1, mockMetadata]);
render(<DomainsPage />);
const searchInput = screen.getByTestId('search-input');
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveAttribute('placeholder', 'Domain filter...');
});
});
describe('Domain Display', () => {
it('shows all domains from API', () => {
const mockDomains = createMockDomains(5);
useDomains.mockReturnValue([mockDomains, 5, mockMetadata]);
render(<DomainsPage />);
// Should render all 5 domains
expect(screen.getByTestId('collection-count')).toHaveTextContent('5');
mockDomains.forEach((domain) => {
expect(screen.getByTestId(`domain-${domain.id}`)).toBeInTheDocument();
});
});
it('displays domain names', () => {
const mockDomains = [
{id: 1, domain: 'example1.com', archive_count: 10, size: 1000},
{id: 2, domain: 'example2.com', archive_count: 20, size: 2000},
{id: 3, domain: 'example3.com', archive_count: 30, size: 3000},
];
useDomains.mockReturnValue([mockDomains, 3, mockMetadata]);
render(<DomainsPage />);
expect(screen.getByTestId('domain-name-1')).toHaveTextContent('example1.com');
expect(screen.getByTestId('domain-name-2')).toHaveTextContent('example2.com');
expect(screen.getByTestId('domain-name-3')).toHaveTextContent('example3.com');
});
it('displays Edit buttons in Manage column', () => {
const mockDomains = createMockDomains(3);
useDomains.mockReturnValue([mockDomains, 3, mockMetadata]);
render(<DomainsPage />);
// Each domain should have an Edit button
mockDomains.forEach((domain) => {
expect(screen.getByTestId(`edit-button-${domain.id}`)).toBeInTheDocument();
});
});
it('Edit button has correct styling', () => {
const mockDomains = createMockDomains(1);
useDomains.mockReturnValue([mockDomains, 1, mockMetadata]);
render(<DomainsPage />);
const editButton = screen.getByTestId('edit-button-1');
expect(editButton).toHaveClass('ui');
expect(editButton).toHaveClass('mini');
expect(editButton).toHaveClass('primary');
expect(editButton).toHaveClass('button');
});
});
describe('Empty and Error States', () => {
it('shows "No items yet" message when no domains', () => {
// Empty array indicates no domains
useDomains.mockReturnValue([[], 0, mockMetadata]);
render(<DomainsPage />);
// Should show empty state message
expect(screen.getByText(/no domains yet/i)).toBeInTheDocument();
expect(screen.getByText(/archive some webpages/i)).toBeInTheDocument();
// Should not show table
expect(screen.queryByTestId('collection-table')).not.toBeInTheDocument();
});
it('shows error message when fetch fails', () => {
// undefined indicates error state
useDomains.mockReturnValue([undefined, 0, mockMetadata]);
render(<DomainsPage />);
// Should show error message
expect(screen.getByTestId('error-message')).toBeInTheDocument();
expect(screen.getByText(/could not fetch domains/i)).toBeInTheDocument();
// Should not show table
expect(screen.queryByTestId('collection-table')).not.toBeInTheDocument();
});
it('does not show "New Domain" button', () => {
const mockDomains = createMockDomains(2);
useDomains.mockReturnValue([mockDomains, 2, mockMetadata]);
render(<DomainsPage />);
// Domains are auto-created, so there should be no "New" button
expect(screen.queryByRole('button', {name: /new/i})).not.toBeInTheDocument();
expect(screen.queryByRole('button', {name: /create/i})).not.toBeInTheDocument();
expect(screen.queryByRole('button', {name: /add/i})).not.toBeInTheDocument();
});
});
describe('Search Integration', () => {
it('disables search when no domains', () => {
useDomains.mockReturnValue([[], 0, mockMetadata]);
render(<DomainsPage />);
const searchInput = screen.getByTestId('search-input');
expect(searchInput).toBeDisabled();
});
it('enables search when domains exist', () => {
const mockDomains = createMockDomains(3);
useDomains.mockReturnValue([mockDomains, 3, mockMetadata]);
render(<DomainsPage />);
const searchInput = screen.getByTestId('search-input');
expect(searchInput).not.toBeDisabled();
});
it('passes search string to CollectionTable', () => {
const mockDomains = createMockDomains(2);
useDomains.mockReturnValue([mockDomains, 2, mockMetadata]);
const mockSetSearchStr = jest.fn();
useOneQuery.mockReturnValue(['example', mockSetSearchStr]);
render(<DomainsPage />);
// Search string should be passed to table
expect(screen.getByTestId('search-filter')).toHaveTextContent('example');
});
});
describe('Page Title', () => {
it('sets page title correctly', () => {
const mockDomains = createMockDomains(1);
useDomains.mockReturnValue([mockDomains, 1, mockMetadata]);
render(<DomainsPage />);
expect(useTitle).toHaveBeenCalledWith('Archive Domains');
});
});
});

View file

@ -0,0 +1,253 @@
import React from 'react';
import {render, screen, waitFor} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {DestinationForm} from './Download';
import {createTestForm} from '../test-utils';
// Mock DirectorySearch component - simplified to avoid useState/useEffect warnings
jest.mock('./Common', () => {
const React = require('react');
return {
...jest.requireActual('./Common'),
DirectorySearch: ({value, onSelect, disabled, required, id}) => (
<div data-testid="directory-search-mock">
<input
data-testid="directory-search-input"
defaultValue={value || ''}
onChange={(e) => onSelect(e.target.value)}
disabled={disabled}
required={required}
id={id}
/>
</div>
),
RequiredAsterisk: () => React.createElement('span', null, '*'),
InfoPopup: ({content}) => React.createElement('span', {'data-testid': 'info-popup'}, content),
};
});
describe('DestinationForm', () => {
describe('Form Integration', () => {
it('renders DirectorySearch with form value', () => {
const form = createTestForm(
{destination: 'videos/test'},
{overrides: {ready: true, loading: false}}
);
render(<DestinationForm form={form} />);
const input = screen.getByTestId('directory-search-input');
expect(input).toHaveValue('videos/test');
});
it('calls form onChange when directory is selected', async () => {
const form = createTestForm(
{destination: ''},
{overrides: {ready: true, loading: false}}
);
render(<DestinationForm form={form} />);
const input = screen.getByTestId('directory-search-input');
// Type a new directory
await userEvent.type(input, 'archive/new');
// Form data should be updated
await waitFor(() => {
expect(form.formData.destination).toBe('archive/new');
});
});
it('displays required indicator when required=true', () => {
const form = createTestForm(
{destination: ''},
{overrides: {ready: true, loading: false}}
);
render(<DestinationForm form={form} required />);
const input = screen.getByTestId('directory-search-input');
expect(input).toHaveAttribute('required');
});
});
describe('Props Handling', () => {
it('uses custom label when provided', () => {
const form = createTestForm(
{destination: ''},
{overrides: {ready: true, loading: false}}
);
render(<DestinationForm form={form} label="Custom Folder" />);
expect(screen.getByText(/custom folder/i)).toBeInTheDocument();
});
it('uses default label when not provided', () => {
const form = createTestForm(
{destination: ''},
{overrides: {ready: true, loading: false}}
);
render(<DestinationForm form={form} />);
expect(screen.getByText(/destination/i)).toBeInTheDocument();
});
it('uses custom name/path when provided', () => {
const form = createTestForm(
{output_dir: 'videos/test'},
{overrides: {ready: true, loading: false}}
);
render(
<DestinationForm
form={form}
name="output_dir"
path="output_dir"
/>
);
const input = screen.getByTestId('directory-search-input');
expect(input).toHaveValue('videos/test');
});
it('shows info popup when infoContent provided', () => {
const form = createTestForm(
{destination: ''},
{overrides: {ready: true, loading: false}}
);
render(
<DestinationForm
form={form}
infoContent="This is helpful information"
/>
);
expect(screen.getByText(/this is helpful information/i)).toBeInTheDocument();
});
});
describe('useForm Integration', () => {
it('gets correct props from form.getCustomProps', () => {
const form = createTestForm(
{destination: 'videos/initial'},
{overrides: {ready: true, loading: false}}
);
const getCustomPropsSpy = jest.spyOn(form, 'getCustomProps');
render(<DestinationForm form={form} required />);
expect(getCustomPropsSpy).toHaveBeenCalledWith({
name: 'destination',
path: 'destination',
required: true
});
});
it('updates form data on selection', async () => {
const form = createTestForm(
{destination: ''},
{overrides: {ready: true, loading: false}}
);
render(<DestinationForm form={form} />);
const input = screen.getByTestId('directory-search-input');
// Select a directory
await userEvent.type(input, 'videos/new-folder');
// Form should be updated
await waitFor(() => {
expect(form.formData.destination).toBe('videos/new-folder');
});
});
});
describe('Edge Cases', () => {
it('works with nested form paths', () => {
const form = createTestForm(
{config: {output: {destination: 'videos/nested'}}},
{overrides: {ready: true, loading: false}}
);
render(
<DestinationForm
form={form}
path="config.output.destination"
name="destination"
/>
);
const input = screen.getByTestId('directory-search-input');
expect(input).toHaveValue('videos/nested');
});
it('handles concurrent field updates', async () => {
const form = createTestForm(
{destination: '', title: ''},
{overrides: {ready: true, loading: false}}
);
render(<DestinationForm form={form} />);
const input = screen.getByTestId('directory-search-input');
// Simulate rapid updates
await userEvent.type(input, 'videos/a');
form.setValue('title', 'Test Title');
await userEvent.type(input, 'bc');
// Destination should have full value
await waitFor(() => {
expect(form.formData.destination).toBe('videos/abc');
});
// Title should also be set
expect(form.formData.title).toBe('Test Title');
});
it('handles empty string as initial value', () => {
const form = createTestForm(
{destination: ''},
{overrides: {ready: true, loading: false}}
);
render(<DestinationForm form={form} />);
const input = screen.getByTestId('directory-search-input');
expect(input).toHaveValue('');
});
it('handles null as initial value', () => {
const form = createTestForm(
{destination: null},
{overrides: {ready: true, loading: false}}
);
render(<DestinationForm form={form} />);
const input = screen.getByTestId('directory-search-input');
expect(input).toHaveValue('');
});
it('handles undefined as initial value', () => {
const form = createTestForm(
{},
{overrides: {ready: true, loading: false}}
);
render(<DestinationForm form={form} />);
const input = screen.getByTestId('directory-search-input');
expect(input).toHaveValue('');
});
});
});

View file

@ -186,9 +186,24 @@ export function NavBar() {
/>
}
// Upgrade available notification - only show on native (non-Docker) installs
let upgradeIcon;
if (status?.update_available && !status?.dockerized) {
const commitsBehind = status.commits_behind || 0;
const branch = status.git_branch || 'unknown';
const icon = <Link to='/admin/settings'>
<Icon name='arrow alternate circle up' size='large' color='green'/>
</Link>;
upgradeIcon = <Popup
content={`Upgrade available: ${commitsBehind} commit(s) behind on ${branch}`}
trigger={icon}
/>;
}
const icons = <React.Fragment>
<NavIconWrapper>{apiDownIcon}</NavIconWrapper>
<NavIconWrapper>{processingIcon}</NavIconWrapper>
<NavIconWrapper>{upgradeIcon}</NavIconWrapper>
<NavIconWrapper>{powerIcon}</NavIconWrapper>
<NavIconWrapper>{warningIcon}</NavIconWrapper>
<NavIconWrapper><ShareButton/></NavIconWrapper>

View file

@ -1,6 +1,7 @@
export const API_URI = process.env && process.env.REACT_APP_API_URI ? process.env.REACT_APP_API_URI : `https://${window.location.host}/api`;
export const VIDEOS_API = `${API_URI}/videos`;
export const ARCHIVES_API = `${API_URI}/archive`;
export const COLLECTIONS_API = `${API_URI}/collections`;
export const OTP_API = `${API_URI}/otp`;
export const ZIM_API = `${API_URI}/zim`;
export const DEFAULT_LIMIT = 20;

View file

@ -1,5 +1,6 @@
import React from "react";
import {postRestart, postShutdown} from "../../api";
import {ThemeContext} from "../../contexts/contexts";
import {checkUpgrade, postRestart, postShutdown, triggerUpgrade} from "../../api";
import {
Button,
Divider,
@ -30,7 +31,7 @@ import QRCode from "react-qr-code";
import {useConfigs, useDockerized} from "../../hooks/customHooks";
import {toast} from "react-semantic-toasts-2";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {SettingsContext} from "../../contexts/contexts";
import {SettingsContext, StatusContext} from "../../contexts/contexts";
import {ConfigsTable} from "./Configs";
import {semanticUIColorMap} from "../Vars";
@ -85,6 +86,89 @@ export function ShutdownButton() {
</APIButton>
}
function UpgradeSegment() {
const {status, fetchStatus} = React.useContext(StatusContext);
const dockerized = useDockerized();
const [upgrading, setUpgrading] = React.useState(false);
const [checking, setChecking] = React.useState(false);
const handleCheckUpgrade = async () => {
setChecking(true);
try {
await checkUpgrade(true); // Force a fresh check
toast({
type: 'info',
title: 'Update Check Complete',
description: 'Checked for updates from git remote.',
time: 3000,
});
} finally {
setChecking(false);
await fetchStatus();
}
};
const handleUpgrade = async () => {
setUpgrading(true);
try {
const response = await triggerUpgrade();
if (response.ok) {
// Redirect to maintenance page
window.location.href = '/maintenance.html';
}
} catch (e) {
setUpgrading(false);
}
};
// Not available in Docker
if (dockerized) {
return <Segment>
<Header as='h3'>System Upgrade</Header>
<p>Upgrades are not available in Docker environments. Please upgrade your Docker images manually.</p>
</Segment>;
}
// No update available
if (!status?.update_available) {
return <Segment>
<Header as='h3'>System Upgrade</Header>
<p>Your WROLPi is up to date.</p>
<p>Version: <strong>v{status?.version}</strong> on branch <strong>{status?.git_branch || 'unknown'}</strong></p>
<APIButton
color='blue'
onClick={handleCheckUpgrade}
disabled={checking}
>
{checking ? 'Checking...' : 'Check for Updates'}
</APIButton>
</Segment>;
}
// Update available
return <Segment>
<Header as='h3'>
<Icon name='arrow alternate circle up'/>
Upgrade Available
</Header>
<p>Branch: <strong>{status?.git_branch}</strong></p>
<p>Current commit: <strong>{status?.current_commit}</strong></p>
<p>Latest commit: <strong>{status?.latest_commit}</strong></p>
<p><strong>{status?.commits_behind}</strong> commit(s) behind</p>
<APIButton
color='green'
size='big'
onClick={handleUpgrade}
disabled={upgrading}
confirmContent='This will upgrade WROLPi. The system will be temporarily unavailable during the upgrade. Are you sure?'
confirmButton='Start Upgrade'
>
{upgrading ? 'Starting Upgrade...' : 'Upgrade Now'}
</APIButton>
</Segment>;
}
export function RestartButton() {
const dockerized = useDockerized();
@ -529,6 +613,8 @@ export function SettingsPage() {
{configsSegment}
<UpgradeSegment/>
<Segment>
<Header as='h1'>Browser Settings</Header>
<APIButton onClick={clearAll}>Show All Hints</APIButton>

View file

@ -0,0 +1,152 @@
import React from 'react';
import {Button, Form, Grid, Message, TextArea} from 'semantic-ui-react';
import {Header, Segment} from '../Theme';
import {TagsSelector, TagsContext} from '../../Tags';
import {WROLModeMessage} from '../Common';
import {DestinationForm} from '../Download';
import {InputForm} from '../../hooks/useForm';
/**
* Reusable form component for editing collections (Domains, Channels, etc).
*
* @param {Object} form - Form object from useForm hook
* @param {Object} metadata - Backend-provided metadata containing fields configuration
* @param {Function} onCancel - Optional callback when cancel is clicked
* @param {String} title - Page title to display in header
* @param {String} wrolModeContent - Content to show in WROL mode message (optional)
* @param {React.ReactNode} actionButtons - Optional additional action buttons to display in the button row
* @param {String} appliedTagName - Optional tag name to display (similar to ChannelEditPage pattern)
*/
export function CollectionEditForm({
form,
metadata,
onCancel,
title,
wrolModeContent,
actionButtons,
appliedTagName
}) {
const {SingleTag} = React.useContext(TagsContext);
if (!metadata) {
return <Message warning>
<Message.Header>No metadata available</Message.Header>
</Message>;
}
const handleSubmit = (e) => {
e.preventDefault();
form.onSubmit();
};
const renderField = (field) => {
const value = form.formData[field.key] || '';
const disabled = field.depends_on && !form.formData[field.depends_on];
switch (field.type) {
case 'text':
if (field.key === 'directory') {
// Use DestinationForm for directory picker
return <DestinationForm
key={field.key}
form={form}
label={field.label}
name={field.key}
path={field.key}
required={field.required}
/>;
}
// Use InputForm for regular text fields
return <InputForm
key={field.key}
form={form}
label={field.label}
name={field.key}
placeholder={field.placeholder}
required={field.required}
/>;
case 'textarea':
// Textarea doesn't have a form component, use manual Field
const [textareaProps] = form.getCustomProps({name: field.key, path: field.key, required: field.required});
return <Form.Field key={field.key}>
<label>{field.label}{field.required && <span style={{color: 'red'}}> *</span>}</label>
<TextArea
placeholder={field.placeholder}
{...textareaProps}
onChange={(e, {value}) => textareaProps.onChange(value)}
rows={3}
/>
</Form.Field>;
case 'tag':
// TagsSelector is custom, use manual Field with form props
const [tagProps] = form.getCustomProps({name: field.key, path: field.key, required: field.required});
return <Form.Field key={field.key} disabled={disabled}>
<label>{field.label}{field.required && <span style={{color: 'red'}}> *</span>}</label>
{disabled && <Message info size='small'>
{metadata.messages?.no_directory || 'Set a directory to enable tagging'}
</Message>}
<TagsSelector
selectedTagNames={tagProps.value ? [tagProps.value] : []}
onChange={(tagNames) => tagProps.onChange(tagNames[0] || null)}
single={true}
disabled={disabled || tagProps.disabled}
/>
{!disabled && tagProps.value && metadata.messages?.tag_will_move && <Message warning size='small'>
{metadata.messages.tag_will_move}
</Message>}
</Form.Field>;
default:
return null;
}
};
return <Segment>
{title && <Header as="h1">{title}</Header>}
{wrolModeContent && <WROLModeMessage content={wrolModeContent}/>}
<Form onSubmit={handleSubmit} loading={form.loading} autoComplete="off">
<Grid stackable columns={2}>
{metadata.fields.map(field => (
<Grid.Row key={field.key}>
<Grid.Column width={16}>
{renderField(field)}
</Grid.Column>
</Grid.Row>
))}
{appliedTagName && <Grid.Row>
<Grid.Column>
<SingleTag name={appliedTagName}/>
</Grid.Column>
</Grid.Row>}
<Grid.Row>
<Grid.Column width={8}>
{actionButtons}
{onCancel && <Button
type='button'
onClick={onCancel}
disabled={form.disabled}
style={{marginTop: '1em'}}
>
Cancel
</Button>}
</Grid.Column>
<Grid.Column width={8}>
<Button
type='submit'
color='violet'
size='big'
floated='right'
disabled={form.disabled}
>
Save
</Button>
</Grid.Column>
</Grid.Row>
</Grid>
</Form>
</Segment>;
}

View file

@ -0,0 +1,347 @@
import React from 'react';
import {render, renderInDarkMode, renderInLightMode, hasInvertedStyling, screen, waitFor, createTestForm} from '../../test-utils';
import {CollectionEditForm} from './CollectionEditForm';
import {createMockMetadata, createMockDomain} from '../../test-utils';
// Mock the TagsSelector component and TagsContext
jest.mock('../../Tags', () => ({
TagsSelector: ({selectedTagNames, onChange, disabled}) => (
<div data-testid="tags-selector" data-disabled={disabled}>
<input
data-testid="tags-input"
value={selectedTagNames?.join(',') || ''}
onChange={(e) => onChange(e.target.value ? [e.target.value] : [])}
disabled={disabled}
/>
</div>
),
TagsContext: {
_currentValue: {
SingleTag: ({name}) => <span data-testid="applied-tag">{name}</span>
}
},
}));
// Mock Common components
jest.mock('../Common', () => ({
...jest.requireActual('../Common'),
WROLModeMessage: () => <div data-testid="wrol-mode-message" />,
}));
// Mock DestinationForm (used for directory field)
jest.mock('../Download', () => ({
DestinationForm: ({form, label, name}) => (
<div data-testid="directory-search">
<label>{label}</label>
<input
value={form.formData[name] || ''}
onChange={(e) => form.setValue(name, e.target.value)}
/>
</div>
),
}));
// Mock InputForm (used for text fields)
jest.mock('../../hooks/useForm', () => ({
...jest.requireActual('../../hooks/useForm'),
InputForm: ({form, label, name}) => (
<div data-testid={`input-${name}`}>
<label>{label}</label>
<input
value={form.formData[name] || ''}
onChange={(e) => form.setValue(name, e.target.value)}
/>
</div>
),
}));
describe('CollectionEditForm', () => {
const mockMetadata = createMockMetadata();
const mockCollection = createMockDomain();
describe('Form Rendering', () => {
it('renders all configured fields', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Check that all fields from metadata are rendered
expect(screen.getByTestId('directory-search')).toBeInTheDocument();
expect(screen.getByTestId('tags-selector')).toBeInTheDocument();
expect(screen.getByPlaceholderText(/optional description/i)).toBeInTheDocument();
// Verify Save button exists
expect(screen.getByRole('button', {name: /save/i})).toBeInTheDocument();
});
it('loads initial values into form', () => {
const collectionWithData = createMockDomain({
description: 'Test description',
directory: 'archive/example.com'
});
const form = createTestForm(collectionWithData);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Check description is loaded
expect(screen.getByPlaceholderText(/optional description/i)).toHaveValue('Test description');
// Check directory field is rendered with the value
const directoryField = screen.getByTestId('directory-search');
expect(directoryField).toBeInTheDocument();
// The mocked DestinationForm renders an input inside the container
const directoryInput = directoryField.querySelector('input');
expect(directoryInput).toHaveValue('archive/example.com');
});
it('renders without errors when form is provided', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
expect(screen.getByRole('button', {name: /save/i})).toBeInTheDocument();
});
});
describe('Field Types', () => {
it('renders textarea for description field', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
const descriptionField = screen.getByPlaceholderText(/optional description/i);
expect(descriptionField.tagName).toBe('TEXTAREA');
});
it('renders directory field using DestinationForm', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
expect(screen.getByTestId('directory-search')).toBeInTheDocument();
});
it('renders tag selector for tag_name field', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
expect(screen.getByTestId('tags-selector')).toBeInTheDocument();
});
});
describe('Field Dependencies (tag requires directory)', () => {
it('disables tag field when directory is empty', () => {
const collectionWithoutDirectory = createMockDomain({
directory: '',
can_be_tagged: false
});
const form = createTestForm(collectionWithoutDirectory);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Should show dependency warning
expect(screen.getByText(/set a directory to enable tagging/i)).toBeInTheDocument();
// Tag selector should be disabled
const tagSelector = screen.getByTestId('tags-selector');
expect(tagSelector).toHaveAttribute('data-disabled', 'true');
});
it('enables tag field when directory is set', () => {
const collectionWithDirectory = createMockDomain({
directory: 'archive/example.com',
can_be_tagged: true
});
const form = createTestForm(collectionWithDirectory);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Dependency warning should not be shown
expect(screen.queryByText(/set a directory to enable tagging/i)).not.toBeInTheDocument();
// Tag selector should not be disabled
const tagSelector = screen.getByTestId('tags-selector');
expect(tagSelector).toHaveAttribute('data-disabled', 'false');
});
it('shows warning when tag is set with directory', () => {
const collectionWithDirectoryAndTag = createMockDomain({
directory: 'archive/example.com',
tag_name: 'News',
can_be_tagged: true
});
const form = createTestForm(collectionWithDirectoryAndTag);
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Should show tag warning
expect(screen.getByText(/tagging will move files/i)).toBeInTheDocument();
});
});
describe('Save/Cancel Actions', () => {
it('shows Cancel button when onCancel provided', () => {
const form = createTestForm(mockCollection);
const mockOnCancel = jest.fn();
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
onCancel={mockOnCancel}
/>
);
expect(screen.getByRole('button', {name: /cancel/i})).toBeInTheDocument();
});
it('disables form during loading', () => {
const form = createTestForm(mockCollection, {
overrides: {loading: true, disabled: true}
});
render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Save button should be disabled during loading
const saveButton = screen.getByRole('button', {name: /save/i});
expect(saveButton).toBeDisabled();
});
});
describe('Error States', () => {
it('handles missing metadata gracefully', () => {
const form = createTestForm(mockCollection);
render(
<CollectionEditForm
form={form}
metadata={null}
/>
);
// Should show warning about missing metadata
expect(screen.getByText(/no metadata available/i)).toBeInTheDocument();
});
});
describe('Theme Integration', () => {
it('applies inverted styling to Segment in dark mode', () => {
const form = createTestForm(mockCollection);
const {container} = renderInDarkMode(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Segment should have inverted class in dark mode
const segment = container.querySelector('.ui.segment');
expect(segment).toBeInTheDocument();
expect(hasInvertedStyling(segment)).toBe(true);
});
it('does not apply inverted styling in light mode', () => {
const form = createTestForm(mockCollection);
const {container} = renderInLightMode(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
// Segment should NOT have inverted class in light mode
const segment = container.querySelector('.ui.segment');
expect(segment).toBeInTheDocument();
expect(hasInvertedStyling(segment)).toBe(false);
});
it('applies dark theme styling to Header in dark mode', () => {
const form = createTestForm(mockCollection);
const {container} = renderInDarkMode(
<CollectionEditForm
form={form}
metadata={mockMetadata}
title="Test Collection"
/>
);
// Header should have dark text color inline style in dark mode
const header = container.querySelector('.ui.header');
expect(header).toBeInTheDocument();
expect(header.style.color).toBe('rgb(238, 238, 238)'); // #eeeeee
});
it('uses theme context from provider', () => {
const form = createTestForm(mockCollection);
const {container} = render(
<CollectionEditForm
form={form}
metadata={mockMetadata}
/>
);
const segment = container.querySelector('.ui.segment');
expect(segment).toBeInTheDocument();
// Should not be inverted by default
expect(hasInvertedStyling(segment)).toBe(false);
});
});
});

View file

@ -0,0 +1,279 @@
import React, {useContext} from 'react';
import {Link} from 'react-router-dom';
import {Message, Placeholder, PlaceholderHeader, PlaceholderLine, Table, TableCell, TableRow} from 'semantic-ui-react';
import _ from 'lodash';
import {SortableTable} from '../SortableTable';
import {humanFileSize} from '../Common';
import {ThemeContext, Media} from '../../contexts/contexts';
import {TagsContext} from '../../Tags';
import {allFrequencyOptions} from '../Vars';
/**
* Format a frequency value (in seconds) to a human-readable string using allFrequencyOptions.
* @param {number|null} frequency - Frequency in seconds
* @returns {string} Human-readable frequency text or '-' if null/undefined
*/
function formatFrequency(frequency) {
if (frequency === null || frequency === undefined) {
return '-';
}
const option = allFrequencyOptions[frequency];
return option ? option.text : `${frequency}s`;
}
/**
* Generate a search link for a collection based on metadata routing configuration.
* Supports both query parameter-based (e.g., ?domain=...) and route-based (e.g., /channel/:id/video) linking.
*
* @param {Object} collection - The collection object
* @param {Object} metadata - Collection metadata with routes configuration
* @param {string} primaryKey - The primary column key to use for the value
* @returns {string|null} The generated link, or null if no link can be generated
*/
function getCollectionSearchLink(collection, metadata, primaryKey) {
const searchRoute = metadata.routes?.search;
if (!searchRoute) {
return null;
}
// Check if metadata specifies a query parameter to use
if (metadata.routes.searchParam) {
// Query parameter-based linking (e.g., /archive?domain=example.com)
return `${searchRoute}?${metadata.routes.searchParam}=${collection[primaryKey]}`;
} else if (searchRoute.includes(':id')) {
// Route parameter-based linking (e.g., /videos/channel/123/video)
// Use id_field if specified (e.g., channel_id for channels), otherwise use id
const idField = metadata.routes.id_field || 'id';
return searchRoute.replace(':id', collection[idField]);
}
// No linking strategy available
return null;
}
/**
* Mobile row component for collections
*/
function MobileCollectionRow({collection, metadata}) {
const {SingleTag} = useContext(TagsContext);
const primaryColumn = metadata.columns[0];
// Use id_field if specified (e.g., channel_id for channels), otherwise use id
const idField = metadata.routes?.id_field || 'id';
const editRoute = metadata.routes?.edit?.replace(':id', collection[idField]);
const searchLink = getCollectionSearchLink(collection, metadata, primaryColumn.key);
return <TableRow verticalAlign='top'>
<TableCell width={10} colSpan={2}>
{searchLink ? (
<Link as='h3' to={searchLink}>
<h3>
{collection[primaryColumn.key]}
</h3>
{collection.tag_name && <SingleTag name={collection.tag_name}/>}
</Link>
) : (
<>
<h3>{collection[primaryColumn.key]}</h3>
{collection.tag_name && <SingleTag name={collection.tag_name}/>}
</>
)}
{metadata.columns
.filter(col => col.type !== 'actions' && col.key !== primaryColumn.key && col.key !== 'tag_name')
.map(col => {
let value = collection[col.key];
if (col.format === 'bytes') {
value = humanFileSize(value);
} else if (col.format === 'frequency') {
value = formatFrequency(value);
}
return (
<p key={col.key}>
{col.label}: {value || '-'}
</p>
);
})
}
</TableCell>
<TableCell width={6} colSpan={2} textAlign='right'>
<p>
{editRoute && <Link className="ui button secondary" to={editRoute}>Edit</Link>}
</p>
</TableCell>
</TableRow>;
}
/**
* Reusable table component for displaying collections (Domains, Channels, etc).
*
* @param {Array} collections - Array of collection objects
* @param {Object} metadata - Backend-provided metadata containing columns, routes, etc.
* @param {String} searchStr - Search filter string (managed by parent)
* @param {Function} onRowClick - Optional callback when a row is clicked
* @param {String} emptyMessage - Message to display when there are no collections
*/
export function CollectionTable({collections, metadata, searchStr = '', onRowClick, emptyMessage = 'No items yet'}) {
const {inverted} = useContext(ThemeContext);
const {SingleTag} = useContext(TagsContext);
// Loading state
if (collections === null) {
return <Placeholder>
<PlaceholderHeader>
<PlaceholderLine/>
<PlaceholderLine/>
</PlaceholderHeader>
</Placeholder>;
}
// Error state
if (collections === undefined) {
return <Message error>
<Message.Header>Could not fetch collections</Message.Header>
</Message>;
}
// Empty state
if (collections && collections.length === 0) {
return <Message>
<Message.Header>{emptyMessage}</Message.Header>
</Message>;
}
// No metadata available (backward compatibility)
if (!metadata) {
return <Message warning>
<Message.Header>No metadata available</Message.Header>
</Message>;
}
// Filter collections by search string
let filteredCollections = collections;
if (searchStr) {
const re = new RegExp(_.escapeRegExp(searchStr), 'i');
filteredCollections = collections.filter(collection => {
// Search across all string fields
return Object.values(collection).some(value => {
if (typeof value === 'string') {
return re.test(value);
}
return false;
});
});
}
// Build row renderer based on metadata
const renderRow = (collection) => {
const cells = metadata.columns.map(col => {
let value = collection[col.key];
// Handle actions column - render action buttons
if (col.type === 'actions') {
// Use id_field if specified (e.g., channel_id for channels), otherwise use id
const idField = metadata.routes?.id_field || 'id';
const editRoute = metadata.routes?.edit?.replace(':id', collection[idField]);
const buttonClass = `ui button secondary ${inverted}`;
return <Table.Cell key={col.key} textAlign={col.align || 'right'}>
{editRoute && <Link className={buttonClass} to={editRoute}>Edit</Link>}
</Table.Cell>;
}
// Format the value based on column configuration
if (col.format === 'bytes') {
value = humanFileSize(value);
} else if (col.format === 'frequency') {
value = formatFrequency(value);
}
// Special handling for tag_name column - render SingleTag component
if (col.key === 'tag_name' && value) {
value = <SingleTag name={value} />;
}
// Special handling for the primary column (usually domain/name)
if (col.key === metadata.columns[0].key) {
const searchLink = getCollectionSearchLink(collection, metadata, col.key);
if (searchLink) {
value = <Link to={searchLink}>{value}</Link>;
}
}
return <Table.Cell key={col.key} textAlign={col.align || 'left'}>
{value || '-'}
</Table.Cell>;
});
return <Table.Row
key={collection.id}
onClick={() => onRowClick && onRowClick(collection)}
style={onRowClick ? {cursor: 'pointer'} : {}}
>
{cells}
</Table.Row>;
};
// Build table headers from metadata (desktop)
const headers = metadata.columns.map((col, index) => {
// Determine width based on column position and type
// Widths use Semantic UI's 16-column grid system and should sum to 16
let width = null;
if (index === 0) {
// First column (name/domain) - gets most space to expand
// For 6 columns: 16 - (2+2+2+2+1) = 7
width = 7;
} else if (col.type === 'actions') {
// Actions column - minimum width to shrink to fit button
width = 1;
} else {
// Other columns (tags, counts, frequency, sizes)
width = 2;
}
return {
key: col.key,
text: col.label,
sortBy: col.sortable ? col.key : null,
width: width,
};
});
// Build mobile headers - simplified columns
const primaryColumn = metadata.columns[0];
const mobileHeaders = [
{
key: primaryColumn.key,
text: primaryColumn.label,
sortBy: primaryColumn.sortable ? primaryColumn.key : null
},
{
key: 'manage',
text: 'Manage'
}
];
// Get default sort column from metadata (first column key)
const defaultSortColumn = metadata.columns[0]?.key || 'id';
return <>
<Media at='mobile'>
<SortableTable
tableProps={{striped: true, size: 'small', unstackable: true}}
data={filteredCollections}
rowFunc={(collection) => <MobileCollectionRow key={collection.id} collection={collection} metadata={metadata}/>}
rowKey='id'
tableHeaders={mobileHeaders}
defaultSortColumn={defaultSortColumn}
/>
</Media>
<Media greaterThanOrEqual='tablet'>
<SortableTable
tableProps={{striped: true, size: 'large', unstackable: true, compact: true}}
data={filteredCollections}
rowFunc={(collection) => renderRow(collection)}
rowKey='id'
tableHeaders={headers}
defaultSortColumn={defaultSortColumn}
/>
</Media>
</>;
}

View file

@ -0,0 +1,156 @@
import React, {useState, useEffect, useCallback} from 'react';
import {Grid, Input} from 'semantic-ui-react';
import Message from 'semantic-ui-react/dist/commonjs/collections/Message';
import {Button, Modal, ModalActions, ModalContent, ModalHeader} from '../Theme';
import {TagsSelector} from '../../Tags';
import {APIButton, Toggle} from '../Common';
/**
* Reusable modal component for tagging collections (Domains, Channels, etc).
* Handles tag selection, directory suggestions, conflict warnings, and state reset on close.
*
* @param {boolean} open - Whether the modal is open
* @param {Function} onClose - Callback when modal is closed
* @param {string} currentTagName - The current tag name of the collection (if any)
* @param {string} originalDirectory - The original directory of the collection (for reset)
* @param {Function} getTagInfo - Async function to fetch tag info: (tagName) => Promise<{suggested_directory, conflict, conflict_message}>
* @param {Function} onSave - Async function called when saving: (tagName, directory) => Promise<void>
* @param {string} collectionName - Name of the collection for toast messages (e.g., "Domain", "Channel")
*/
export function CollectionTagModal({
open,
onClose,
currentTagName,
originalDirectory,
getTagInfo,
onSave,
collectionName = 'Collection',
}) {
const [newTagName, setNewTagName] = useState(currentTagName || null);
const [moveToTagDirectory, setMoveToTagDirectory] = useState(true);
const [newTagDirectory, setNewTagDirectory] = useState(originalDirectory || '');
const [conflictMessage, setConflictMessage] = useState(null);
// Reset state when modal opens or collection changes
useEffect(() => {
if (open) {
setNewTagName(currentTagName || null);
setNewTagDirectory(originalDirectory || '');
setConflictMessage(null);
}
}, [open, currentTagName, originalDirectory]);
// Handle modal close - reset to original values
const handleClose = useCallback(() => {
setNewTagName(currentTagName || null);
setNewTagDirectory(originalDirectory || '');
setConflictMessage(null);
onClose();
}, [currentTagName, originalDirectory, onClose]);
// Handle tag selection change - fetch tag info
const handleTagChange = useCallback(async (tagName) => {
setNewTagName(tagName);
setConflictMessage(null);
// Always fetch tag info when tag changes (including removal)
try {
const tagInfo = await getTagInfo(tagName);
if (tagInfo) {
// Handle both response formats:
// - Object format: {suggested_directory, conflict, conflict_message}
// - String format: just the directory path (legacy channel API)
if (typeof tagInfo === 'string') {
// Legacy format - just a directory string
setNewTagDirectory(tagInfo);
} else if (tagInfo.suggested_directory) {
setNewTagDirectory(tagInfo.suggested_directory);
} else if (!tagName) {
// If tag removed and no suggestion, reset to original
setNewTagDirectory(originalDirectory || '');
}
// Handle conflict message (only in object format)
if (typeof tagInfo === 'object' && tagInfo.conflict && tagInfo.conflict_message) {
setConflictMessage(tagInfo.conflict_message);
}
} else if (!tagName) {
// If no tag info returned and tag was removed, reset to original
setNewTagDirectory(originalDirectory || '');
}
} catch (e) {
console.error(`Failed to get tag info for ${collectionName}`, e);
// Don't block the UI if tag info fails
}
}, [getTagInfo, originalDirectory, collectionName]);
// Handle save
const handleSave = useCallback(async () => {
await onSave(newTagName, moveToTagDirectory ? newTagDirectory : null);
handleClose();
}, [newTagName, moveToTagDirectory, newTagDirectory, onSave, handleClose]);
const modalTitle = currentTagName ? 'Modify Tag' : 'Add Tag';
const saveButtonText = moveToTagDirectory ? 'Move' : 'Save';
return (
<Modal
open={open}
onClose={handleClose}
closeIcon
>
<ModalHeader>{modalTitle}</ModalHeader>
<ModalContent>
<Grid columns={1}>
<Grid.Row>
<Grid.Column>
<TagsSelector
limit={1}
selectedTagNames={newTagName ? [newTagName] : []}
onAdd={handleTagChange}
onRemove={() => handleTagChange(null)}
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<Toggle
label='Move to directory: '
checked={moveToTagDirectory}
onChange={setMoveToTagDirectory}
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<Input
fluid
value={newTagDirectory}
onChange={(e, {value}) => setNewTagDirectory(value)}
disabled={!moveToTagDirectory}
/>
</Grid.Column>
</Grid.Row>
{conflictMessage && (
<Grid.Row>
<Grid.Column>
<Message warning>
<Message.Header>Directory Conflict</Message.Header>
<p>{conflictMessage}</p>
</Message>
</Grid.Column>
</Grid.Row>
)}
</Grid>
</ModalContent>
<ModalActions>
<Button onClick={handleClose}>Cancel</Button>
<APIButton
color='violet'
onClick={handleSave}
obeyWROLMode={true}
>{saveButtonText}</APIButton>
</ModalActions>
</Modal>
);
}

View file

@ -0,0 +1,442 @@
import React from 'react';
import {render, screen, waitFor, act} from '../../test-utils';
import userEvent from '@testing-library/user-event';
import {CollectionTagModal} from './CollectionTagModal';
// Mock Theme components
jest.mock('../Theme', () => ({
...jest.requireActual('../Theme'),
Modal: ({open, onClose, children, closeIcon}) => {
if (!open) return null;
return (
<div data-testid="modal">
{closeIcon && <button data-testid="close-icon" onClick={onClose}>×</button>}
{children}
</div>
);
},
ModalHeader: ({children}) => <div data-testid="modal-header">{children}</div>,
ModalContent: ({children}) => <div data-testid="modal-content">{children}</div>,
ModalActions: ({children}) => <div data-testid="modal-actions">{children}</div>,
Button: ({children, onClick, ...props}) => (
<button onClick={onClick} data-testid={`button-${children}`} {...props}>{children}</button>
),
}));
// Mock Common components
jest.mock('../Common', () => ({
...jest.requireActual('../Common'),
Toggle: ({label, checked, onChange}) => (
<label data-testid="toggle">
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
data-testid="toggle-checkbox"
/>
{label}
</label>
),
APIButton: ({children, onClick, ...props}) => (
<button onClick={onClick} data-testid={`api-button-${children}`} {...props}>{children}</button>
),
}));
// Mock TagsSelector
jest.mock('../../Tags', () => ({
TagsSelector: ({selectedTagNames, onAdd, onRemove, limit}) => (
<div data-testid="tags-selector">
<span data-testid="selected-tags">{selectedTagNames?.join(', ') || 'none'}</span>
<button data-testid="add-tag" onClick={() => onAdd('test-tag')}>Add Tag</button>
<button data-testid="remove-tag" onClick={() => onRemove('test-tag')}>Remove Tag</button>
</div>
),
}));
describe('CollectionTagModal', () => {
const defaultProps = {
open: true,
onClose: jest.fn(),
currentTagName: null,
originalDirectory: '/original/directory',
getTagInfo: jest.fn(),
onSave: jest.fn(),
collectionName: 'Test',
};
beforeEach(() => {
jest.clearAllMocks();
});
describe('Modal Rendering', () => {
it('renders modal when open is true', () => {
render(<CollectionTagModal {...defaultProps} />);
expect(screen.getByTestId('modal')).toBeInTheDocument();
});
it('does not render modal when open is false', () => {
render(<CollectionTagModal {...defaultProps} open={false} />);
expect(screen.queryByTestId('modal')).not.toBeInTheDocument();
});
it('shows "Add Tag" header when no current tag', () => {
render(<CollectionTagModal {...defaultProps} currentTagName={null} />);
expect(screen.getByTestId('modal-header')).toHaveTextContent('Add Tag');
});
it('shows "Modify Tag" header when there is a current tag', () => {
render(<CollectionTagModal {...defaultProps} currentTagName="existing-tag" />);
expect(screen.getByTestId('modal-header')).toHaveTextContent('Modify Tag');
});
});
describe('Directory Input', () => {
it('displays original directory in input field', () => {
render(<CollectionTagModal {...defaultProps} />);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/original/directory');
});
it('updates directory when user types', async () => {
render(<CollectionTagModal {...defaultProps} />);
const input = screen.getByRole('textbox');
await userEvent.clear(input);
await userEvent.type(input, '/new/directory');
expect(input).toHaveValue('/new/directory');
});
it('disables directory input when move toggle is off', async () => {
render(<CollectionTagModal {...defaultProps} />);
const toggleCheckbox = screen.getByTestId('toggle-checkbox');
await userEvent.click(toggleCheckbox); // Turn off move to directory
const input = screen.getByRole('textbox');
expect(input).toBeDisabled();
});
});
describe('Tag Info Fetching', () => {
it('fetches tag info when tag is added', async () => {
const mockGetTagInfo = jest.fn().mockResolvedValue({
suggested_directory: '/suggested/directory',
conflict: false,
conflict_message: null,
});
render(<CollectionTagModal {...defaultProps} getTagInfo={mockGetTagInfo} />);
const addTagButton = screen.getByTestId('add-tag');
await userEvent.click(addTagButton);
// Wait for all async state updates to complete by checking the final result
await waitFor(() => {
expect(mockGetTagInfo).toHaveBeenCalledWith('test-tag');
// Also verify the directory was updated (this ensures async call completed)
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/suggested/directory');
});
});
it('updates directory with suggested directory from tag info', async () => {
const mockGetTagInfo = jest.fn().mockResolvedValue({
suggested_directory: '/suggested/directory',
conflict: false,
conflict_message: null,
});
render(<CollectionTagModal {...defaultProps} getTagInfo={mockGetTagInfo} />);
const addTagButton = screen.getByTestId('add-tag');
await userEvent.click(addTagButton);
await waitFor(() => {
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/suggested/directory');
});
});
it('handles legacy string format from tag info', async () => {
const mockGetTagInfo = jest.fn().mockResolvedValue('/legacy/directory');
render(<CollectionTagModal {...defaultProps} getTagInfo={mockGetTagInfo} />);
const addTagButton = screen.getByTestId('add-tag');
await userEvent.click(addTagButton);
await waitFor(() => {
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/legacy/directory');
});
});
it('fetches tag info when tag is removed', async () => {
const mockGetTagInfo = jest.fn().mockResolvedValue(null);
render(<CollectionTagModal {...defaultProps} getTagInfo={mockGetTagInfo} currentTagName="existing-tag" />);
const removeTagButton = screen.getByTestId('remove-tag');
await userEvent.click(removeTagButton);
// Wait for all async state updates to complete
await waitFor(() => {
expect(mockGetTagInfo).toHaveBeenCalledWith(null);
// Also verify the directory was reset (ensures async call completed)
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/original/directory');
});
});
it('updates directory when tag is removed and backend returns suggestion', async () => {
const mockGetTagInfo = jest.fn().mockResolvedValue({
suggested_directory: '/untagged/directory',
conflict: false,
conflict_message: null,
});
render(<CollectionTagModal {...defaultProps} getTagInfo={mockGetTagInfo} currentTagName="existing-tag" />);
const removeTagButton = screen.getByTestId('remove-tag');
await userEvent.click(removeTagButton);
await waitFor(() => {
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/untagged/directory');
});
});
it('resets to original directory when tag is removed and no suggestion', async () => {
const mockGetTagInfo = jest.fn().mockResolvedValue(null);
render(<CollectionTagModal {...defaultProps} getTagInfo={mockGetTagInfo} originalDirectory="/original/path" />);
// First add a tag - wait for async to complete including state updates
const addTagButton = screen.getByTestId('add-tag');
await act(async () => {
await userEvent.click(addTagButton);
});
// Wait for first getTagInfo call to complete AND directory state to settle
// When getTagInfo returns null, the component sets directory to originalDirectory
await waitFor(() => {
expect(mockGetTagInfo).toHaveBeenCalledWith('test-tag');
const input = screen.getByRole('textbox');
// After null response, directory is reset to original
expect(input).toHaveValue('/original/path');
});
// Then remove the tag - wrap in act to catch async state updates
const removeTagButton = screen.getByTestId('remove-tag');
await act(async () => {
await userEvent.click(removeTagButton);
});
// Verify all state updates completed
expect(mockGetTagInfo).toHaveBeenCalledWith(null);
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/original/path');
});
});
describe('Conflict Handling', () => {
it('displays conflict warning when tag info indicates conflict', async () => {
const mockGetTagInfo = jest.fn().mockResolvedValue({
suggested_directory: '/conflict/directory',
conflict: true,
conflict_message: 'Directory already in use',
});
render(<CollectionTagModal {...defaultProps} getTagInfo={mockGetTagInfo} />);
const addTagButton = screen.getByTestId('add-tag');
await userEvent.click(addTagButton);
// Wait for all async state updates including conflict message and directory
await waitFor(() => {
expect(screen.getByText('Directory Conflict')).toBeInTheDocument();
expect(screen.getByText('Directory already in use')).toBeInTheDocument();
// Also check directory was updated (ensures all state updates complete)
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/conflict/directory');
});
});
it('clears conflict warning when tag is changed', async () => {
const mockGetTagInfo = jest.fn()
.mockResolvedValueOnce({
suggested_directory: '/conflict/directory',
conflict: true,
conflict_message: 'Directory already in use',
})
.mockResolvedValueOnce({
suggested_directory: '/no-conflict/directory',
conflict: false,
conflict_message: null,
});
render(<CollectionTagModal {...defaultProps} getTagInfo={mockGetTagInfo} />);
// Add first tag - shows conflict
const addTagButton = screen.getByTestId('add-tag');
await userEvent.click(addTagButton);
// Wait for first async call to complete (conflict message AND directory update)
await waitFor(() => {
expect(screen.getByText('Directory Conflict')).toBeInTheDocument();
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/conflict/directory');
});
// Add different tag - conflict should clear
await userEvent.click(addTagButton);
// Wait for second async call to complete (no conflict AND new directory)
await waitFor(() => {
expect(screen.queryByText('Directory Conflict')).not.toBeInTheDocument();
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/no-conflict/directory');
});
});
});
describe('Modal Close Behavior', () => {
it('calls onClose when cancel button is clicked', async () => {
const mockOnClose = jest.fn();
render(<CollectionTagModal {...defaultProps} onClose={mockOnClose} />);
const cancelButton = screen.getByTestId('button-Cancel');
await userEvent.click(cancelButton);
expect(mockOnClose).toHaveBeenCalled();
});
it('resets directory to original value when modal is closed', async () => {
const mockOnClose = jest.fn();
const mockGetTagInfo = jest.fn().mockResolvedValue({
suggested_directory: '/changed/directory',
conflict: false,
conflict_message: null,
});
const {rerender} = render(
<CollectionTagModal
{...defaultProps}
onClose={mockOnClose}
getTagInfo={mockGetTagInfo}
originalDirectory="/original/directory"
/>
);
// Change directory by adding a tag
const addTagButton = screen.getByTestId('add-tag');
await userEvent.click(addTagButton);
await waitFor(() => {
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/changed/directory');
});
// Close the modal
const cancelButton = screen.getByTestId('button-Cancel');
await userEvent.click(cancelButton);
// Reopen modal
rerender(
<CollectionTagModal
{...defaultProps}
open={true}
onClose={mockOnClose}
getTagInfo={mockGetTagInfo}
originalDirectory="/original/directory"
/>
);
// Directory should be reset to original
await waitFor(() => {
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/original/directory');
});
});
});
describe('Save Behavior', () => {
it('calls onSave with tag name and directory when move toggle is on', async () => {
const mockOnClose = jest.fn();
// Use mockResolvedValue to make onSave async - this helps React batch state updates properly
const mockOnSave = jest.fn().mockResolvedValue(undefined);
const mockGetTagInfo = jest.fn().mockResolvedValue({
suggested_directory: '/suggested/directory',
conflict: false,
});
render(
<CollectionTagModal
{...defaultProps}
onSave={mockOnSave}
onClose={mockOnClose}
getTagInfo={mockGetTagInfo}
/>
);
// Add a tag
const addTagButton = screen.getByTestId('add-tag');
await userEvent.click(addTagButton);
// Wait for the directory to be updated from getTagInfo
await waitFor(() => {
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/suggested/directory');
});
// Click save/move button and wait for all async operations to complete
await act(async () => {
await userEvent.click(screen.getByTestId('api-button-Move'));
});
// Verify the save was called
expect(mockOnSave).toHaveBeenCalledWith('test-tag', '/suggested/directory');
expect(mockOnClose).toHaveBeenCalled();
});
it('calls onSave with null directory when move toggle is off', async () => {
const mockOnClose = jest.fn();
// Use mockResolvedValue to make onSave async - this helps React batch state updates properly
const mockOnSave = jest.fn().mockResolvedValue(undefined);
const mockGetTagInfo = jest.fn().mockResolvedValue({
suggested_directory: '/suggested/directory',
conflict: false,
});
render(
<CollectionTagModal
{...defaultProps}
onSave={mockOnSave}
onClose={mockOnClose}
getTagInfo={mockGetTagInfo}
/>
);
// Add a tag
const addTagButton = screen.getByTestId('add-tag');
await userEvent.click(addTagButton);
// Wait for all async state updates to complete
await waitFor(() => {
expect(mockGetTagInfo).toHaveBeenCalled();
const input = screen.getByRole('textbox');
expect(input).toHaveValue('/suggested/directory');
});
// Turn off move toggle
const toggleCheckbox = screen.getByTestId('toggle-checkbox');
await userEvent.click(toggleCheckbox);
// Click save button and wait for all async operations to complete
await act(async () => {
await userEvent.click(screen.getByTestId('api-button-Save'));
});
// Verify the save was called
expect(mockOnSave).toHaveBeenCalledWith('test-tag', null);
expect(mockOnClose).toHaveBeenCalled();
});
});
});

View file

@ -2,6 +2,7 @@ import React, {useContext, useEffect, useRef, useState} from "react";
import {
ApiDownError,
createChannel,
fetchChannels,
fetchDecoded,
fetchDomains,
fetchFilesProgress,
@ -10,6 +11,7 @@ import {
getChannel,
getChannels,
getConfigs,
getDomain,
getDownloads,
getFiles,
getInventory,
@ -32,6 +34,7 @@ import {
setHotspot,
setThrottle,
updateChannel,
updateDomain,
} from "../api";
import {createSearchParams, useLocation, useSearchParams} from "react-router-dom";
import {enumerate, filterToMimetypes, humanFileSize, secondsToFullDuration} from "../components/Common";
@ -191,17 +194,21 @@ export const useAllQuery = (name) => {
export const useDomains = () => {
const [domains, setDomains] = useState(null);
const [total, setTotal] = useState(null);
const [metadata, setMetadata] = useState(null);
const _fetchDomains = async () => {
setDomains(null);
setTotal(0);
setMetadata(null);
try {
let [domains, total] = await fetchDomains();
let [domains, total, metadata] = await fetchDomains();
setDomains(domains);
setTotal(total);
setMetadata(metadata);
} catch (e) {
setDomains(undefined); // Display error.
setTotal(0);
setMetadata(undefined);
}
}
@ -209,7 +216,86 @@ export const useDomains = () => {
_fetchDomains();
}, []);
return [domains, total];
return [domains, total, metadata];
}
export const useCollectionMetadata = (kind) => {
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchMetadata = async () => {
setLoading(true);
setError(null);
try {
if (kind === 'domain') {
const [, , metadata] = await fetchDomains();
setMetadata(metadata);
} else {
// Future: Support channels metadata
setMetadata(null);
}
} catch (e) {
console.error(e);
setError(e);
setMetadata(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchMetadata();
}, [kind]);
return {metadata, loading, error, fetchMetadata};
}
export const useDomain = (domainId) => {
const emptyDomain = {
domain: '',
directory: '',
description: '',
tag_name: null,
};
const fetchDomain = async () => {
if (!domainId) {
console.debug('Not fetching domain because no domainId is provided');
return;
}
const d = await getDomain(domainId);
// Prevent controlled to uncontrolled
d.directory = d.directory || '';
d.description = d.description || '';
d.tag_name = d.tag_name || null;
return d;
};
const submitDomain = async () => {
const body = {
directory: form.formData.directory,
description: form.formData.description,
// Convert null to empty string - backend expects "" to clear tag
tag_name: form.formData.tag_name === null ? '' : form.formData.tag_name,
};
return await updateDomain(domainId, body);
};
const form = useForm({
fetcher: fetchDomain,
emptyFormData: emptyDomain,
clearOnSuccess: false,
submitter: submitDomain
});
React.useEffect(() => {
form.fetcher();
}, [domainId]);
const domain = form.formData;
return {domain, form, fetchDomain: form.fetcher};
}
export const useArchive = (archiveId) => {
@ -522,23 +608,31 @@ export const useChannel = (channel_id) => {
export const useChannels = () => {
const [channels, setChannels] = useState(null);
const [total, setTotal] = useState(null);
const [metadata, setMetadata] = useState(null);
const fetchChannels = async () => {
const _fetchChannels = async () => {
setChannels(null);
setTotal(0);
setMetadata(null);
try {
const c = await getChannels();
setChannels(c);
let [channels, total, metadata] = await fetchChannels();
setChannels(channels);
setTotal(total);
setMetadata(metadata);
} catch (e) {
console.error(e);
setChannels(undefined); // Could not get Channels, display error.
setChannels(undefined); // Display error.
setTotal(0);
setMetadata(undefined);
}
}
useEffect(() => {
fetchChannels();
_fetchChannels();
}, []);
return {channels, fetchChannels}
return [channels, total, metadata];
}
export const useSearchRecentFiles = () => {

View file

@ -113,10 +113,12 @@ export function useForm({
reset();
}
await onSuccess();
} catch (error) {
await onFailure(error);
throw error;
} finally {
setLoading(false);
setDisabled(false);
await onFailure();
}
}

View file

@ -0,0 +1,998 @@
import React from 'react';
import {renderHook, act, waitFor} from '@testing-library/react';
import {render, screen, fireEvent} from '@testing-library/react';
import {useForm, commaSeparatedValidator, InputForm, NumberInputForm, UrlInput, ToggleForm, UrlsTextarea} from './useForm';
import {renderWithProviders} from '../test-utils';
// Mock lodash debounce to control timing in tests
jest.mock('lodash', () => {
const actual = jest.requireActual('lodash');
return {
...actual,
debounce: (fn) => {
const debounced = (...args) => fn(...args);
debounced.cancel = jest.fn();
return debounced;
},
};
});
describe('useForm', () => {
let consoleErrorSpy;
let consoleDebugSpy;
beforeEach(() => {
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {
});
consoleDebugSpy = jest.spyOn(console, 'debug').mockImplementation(() => {
});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
consoleDebugSpy.mockRestore();
});
describe('initialization', () => {
it('initializes with defaultFormData', () => {
const defaultData = {name: 'test', value: 123};
const {result} = renderHook(() => useForm({
defaultFormData: defaultData,
submitter: jest.fn(),
}));
expect(result.current.formData).toEqual(defaultData);
});
it('starts with correct initial states', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {},
submitter: jest.fn(),
}));
expect(result.current.dirty).toBe(false);
expect(result.current.loading).toBe(false);
expect(result.current.disabled).toBe(false);
// Ready starts true when there are no validators or required fields
expect(result.current.ready).toBe(true);
});
it('calls fetcher on mount if provided', async () => {
const fetcher = jest.fn().mockResolvedValue({fetched: 'data'});
renderHook(() => useForm({
fetcher,
defaultFormData: {},
submitter: jest.fn(),
}));
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(1);
});
});
it('updates formData when fetcher resolves', async () => {
const fetchedData = {name: 'fetched', value: 456};
const fetcher = jest.fn().mockResolvedValue(fetchedData);
const {result} = renderHook(() => useForm({
fetcher,
defaultFormData: {name: 'default'},
submitter: jest.fn(),
}));
await waitFor(() => {
expect(result.current.formData).toEqual(fetchedData);
});
});
it('initializes with empty object when no defaultFormData provided', () => {
const {result} = renderHook(() => useForm({
submitter: jest.fn(),
}));
expect(result.current.formData).toEqual({});
});
});
describe('submit lifecycle', () => {
it('refuses to submit when not ready', async () => {
const submitter = jest.fn();
const {result} = renderHook(() => useForm({
defaultFormData: {},
submitter,
}));
// Force ready to false by adding a required field that's empty
act(() => {
result.current.getInputProps({name: 'required_field', required: true});
});
await act(async () => {
await result.current.onSubmit();
});
expect(submitter).not.toHaveBeenCalled();
expect(consoleErrorSpy).toHaveBeenCalledWith('Refusing to submit form because it is not ready');
});
it('sets loading and disabled during submission', async () => {
let resolveSubmit;
const submitter = jest.fn(() => new Promise(resolve => {
resolveSubmit = resolve;
}));
const {result} = renderHook(() => useForm({
defaultFormData: {name: 'test'},
submitter,
}));
// Start submission
let submitPromise;
act(() => {
submitPromise = result.current.onSubmit();
});
// During submission - loading and disabled are set synchronously
expect(result.current.loading).toBe(true);
expect(result.current.disabled).toBe(true);
// Note: ready is set to false in onSubmit, but the useEffect that computes
// ready may run after this check. The key assertion is loading/disabled.
// Complete submission
await act(async () => {
resolveSubmit();
await submitPromise;
});
// After submission - states are reset
expect(result.current.loading).toBe(false);
expect(result.current.disabled).toBe(false);
});
it('calls submitter with form data', async () => {
const formData = {name: 'test', value: 123};
const submitter = jest.fn().mockResolvedValue(undefined);
const {result} = renderHook(() => useForm({
defaultFormData: formData,
submitter,
}));
await act(async () => {
await result.current.onSubmit();
});
expect(submitter).toHaveBeenCalledWith(formData);
});
it('calls onSuccess after successful submit', async () => {
const onSuccess = jest.fn();
const submitter = jest.fn().mockResolvedValue(undefined);
const {result} = renderHook(() => useForm({
defaultFormData: {name: 'test'},
submitter,
onSuccess,
}));
await act(async () => {
await result.current.onSubmit();
});
expect(onSuccess).toHaveBeenCalled();
});
it('does not call onFailure on successful submit', async () => {
const onSuccess = jest.fn();
const onFailure = jest.fn();
const submitter = jest.fn().mockResolvedValue(undefined);
const {result} = renderHook(() => useForm({
defaultFormData: {name: 'test'},
submitter,
onSuccess,
onFailure,
}));
await act(async () => {
await result.current.onSubmit();
});
expect(onSuccess).toHaveBeenCalled();
expect(onFailure).not.toHaveBeenCalled();
});
it('clears form when clearOnSuccess is true', async () => {
const emptyFormData = {name: '', value: 0};
const submitter = jest.fn().mockResolvedValue(undefined);
const {result} = renderHook(() => useForm({
defaultFormData: {name: 'test', value: 123},
emptyFormData,
submitter,
clearOnSuccess: true,
}));
await act(async () => {
await result.current.onSubmit();
});
expect(result.current.formData).toEqual(emptyFormData);
});
it('re-fetches when fetchOnSuccess is true', async () => {
const fetchedData = {name: 'refetched'};
const fetcher = jest.fn().mockResolvedValue(fetchedData);
const submitter = jest.fn().mockResolvedValue(undefined);
const {result} = renderHook(() => useForm({
fetcher,
defaultFormData: {name: 'initial'},
submitter,
fetchOnSuccess: true,
}));
// Wait for initial fetch
await waitFor(() => {
expect(fetcher).toHaveBeenCalledTimes(1);
});
await act(async () => {
await result.current.onSubmit();
});
// Should have been called twice (init + after submit)
expect(fetcher).toHaveBeenCalledTimes(2);
});
it('handles submitter errors gracefully', async () => {
const submitter = jest.fn().mockRejectedValue(new Error('Submit failed'));
const onFailure = jest.fn();
const {result} = renderHook(() => useForm({
defaultFormData: {name: 'test'},
submitter,
onFailure,
}));
await act(async () => {
try {
await result.current.onSubmit();
} catch (e) {
// Expected to throw
}
});
// Should still reset loading/disabled states
expect(result.current.loading).toBe(false);
expect(result.current.disabled).toBe(false);
expect(onFailure).toHaveBeenCalled();
});
});
describe('form data management', () => {
it('setValue updates value at path', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {name: 'original'},
submitter: jest.fn(),
}));
act(() => {
result.current.setValue('name', 'updated');
});
expect(result.current.formData.name).toBe('updated');
});
it('setValue handles nested paths', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {config: {nested: {value: 'original'}}},
submitter: jest.fn(),
}));
act(() => {
result.current.setValue('config.nested.value', 'updated');
});
expect(result.current.formData.config.nested.value).toBe('updated');
});
it('handleInputEvent extracts value from event', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {username: ''},
submitter: jest.fn(),
}));
act(() => {
result.current.handleInputEvent({
preventDefault: jest.fn(),
target: {
type: 'text',
value: 'newvalue',
name: 'username',
dataset: {},
},
});
});
expect(result.current.formData.username).toBe('newvalue');
});
it('handleInputEvent converts number inputs to integers', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {count: 0},
submitter: jest.fn(),
}));
act(() => {
result.current.handleInputEvent({
preventDefault: jest.fn(),
target: {
type: 'number',
value: '42',
name: 'count',
dataset: {},
},
});
});
expect(result.current.formData.count).toBe(42);
expect(typeof result.current.formData.count).toBe('number');
});
it('handleInputEvent uses data-path attribute when available', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {nested: {field: ''}},
submitter: jest.fn(),
}));
act(() => {
result.current.handleInputEvent({
preventDefault: jest.fn(),
target: {
type: 'text',
value: 'pathvalue',
name: 'ignored',
dataset: {path: 'nested.field'},
},
});
});
expect(result.current.formData.nested.field).toBe('pathvalue');
});
it('reset restores to emptyFormData when provided', () => {
const emptyFormData = {name: '', value: null};
const {result} = renderHook(() => useForm({
defaultFormData: {name: 'test', value: 123},
emptyFormData,
submitter: jest.fn(),
}));
act(() => {
result.current.setValue('name', 'modified');
});
act(() => {
result.current.reset();
});
expect(result.current.formData).toEqual(emptyFormData);
});
it('reset restores to defaultFormData when emptyFormData not provided', () => {
const defaultFormData = {name: 'default', value: 100};
const {result} = renderHook(() => useForm({
defaultFormData,
submitter: jest.fn(),
}));
act(() => {
result.current.setValue('name', 'modified');
});
act(() => {
result.current.reset();
});
expect(result.current.formData).toEqual(defaultFormData);
});
});
describe('dirty tracking', () => {
it('becomes dirty when formData differs from defaultFormData', async () => {
const {result} = renderHook(() => useForm({
defaultFormData: {name: 'original'},
submitter: jest.fn(),
}));
expect(result.current.dirty).toBe(false);
act(() => {
result.current.setValue('name', 'changed');
});
await waitFor(() => {
expect(result.current.dirty).toBe(true);
});
});
it('calls onDirty callback when dirty becomes true', async () => {
const onDirty = jest.fn();
const {result} = renderHook(() => useForm({
defaultFormData: {name: 'original'},
submitter: jest.fn(),
onDirty,
}));
act(() => {
result.current.setValue('name', 'changed');
});
await waitFor(() => {
expect(onDirty).toHaveBeenCalled();
});
});
it('uses deep equality for nested objects', async () => {
const {result} = renderHook(() => useForm({
defaultFormData: {config: {nested: 'value'}},
submitter: jest.fn(),
}));
expect(result.current.dirty).toBe(false);
act(() => {
result.current.setValue('config.nested', 'different');
});
await waitFor(() => {
expect(result.current.dirty).toBe(true);
});
});
});
describe('validation', () => {
it('validates field and sets error when invalid', async () => {
const validator = (value) => value.length < 3 ? 'Too short' : null;
const {result} = renderHook(() => useForm({
defaultFormData: {name: ''},
submitter: jest.fn(),
}));
act(() => {
result.current.getInputProps({name: 'name', validator});
});
act(() => {
result.current.handleInputEvent({
preventDefault: jest.fn(),
target: {
type: 'text',
value: 'ab',
name: 'name',
dataset: {},
},
});
});
await waitFor(() => {
expect(result.current.ready).toBe(false);
});
});
it('clears error when value becomes valid', async () => {
const validator = (value) => value.length < 3 ? 'Too short' : null;
const {result} = renderHook(() => useForm({
defaultFormData: {name: ''},
submitter: jest.fn(),
}));
act(() => {
result.current.getInputProps({name: 'name', validator});
});
// Set invalid value
act(() => {
result.current.handleInputEvent({
preventDefault: jest.fn(),
target: {type: 'text', value: 'ab', name: 'name', dataset: {}},
});
});
// Set valid value
act(() => {
result.current.handleInputEvent({
preventDefault: jest.fn(),
target: {type: 'text', value: 'valid', name: 'name', dataset: {}},
});
});
await waitFor(() => {
expect(result.current.ready).toBe(true);
});
});
it('handles validator that throws exception', () => {
const validator = () => {
throw new Error('Validator crashed');
};
const {result} = renderHook(() => useForm({
defaultFormData: {name: ''},
submitter: jest.fn(),
}));
act(() => {
result.current.getInputProps({name: 'name', validator});
});
// Should not throw, just log error
act(() => {
result.current.handleInputEvent({
preventDefault: jest.fn(),
target: {type: 'text', value: 'test', name: 'name', dataset: {}},
});
});
expect(consoleErrorSpy).toHaveBeenCalledWith('Failed to validate name');
});
});
describe('required fields', () => {
it('form is not ready when required field is empty', async () => {
const {result} = renderHook(() => useForm({
defaultFormData: {name: ''},
submitter: jest.fn(),
}));
act(() => {
result.current.getInputProps({name: 'name', required: true});
});
await waitFor(() => {
expect(result.current.ready).toBe(false);
});
});
it('form becomes ready when required field has value', async () => {
const {result} = renderHook(() => useForm({
defaultFormData: {name: 'has value'},
submitter: jest.fn(),
}));
act(() => {
result.current.getInputProps({name: 'name', required: true});
});
await waitFor(() => {
expect(result.current.ready).toBe(true);
});
});
it('addRequires marks field as required', async () => {
const {result} = renderHook(() => useForm({
defaultFormData: {field: ''},
submitter: jest.fn(),
}));
expect(result.current.ready).toBe(true);
act(() => {
result.current.getCustomProps({name: 'field', required: true});
});
await waitFor(() => {
expect(result.current.ready).toBe(false);
});
});
});
describe('props generators', () => {
describe('getInputProps', () => {
it('returns props suitable for input elements', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {email: 'test@example.com'},
submitter: jest.fn(),
}));
const [inputProps] = result.current.getInputProps({name: 'email'});
expect(inputProps).toHaveProperty('type', 'text');
expect(inputProps).toHaveProperty('disabled', false);
expect(inputProps).toHaveProperty('value', 'test@example.com');
expect(inputProps).toHaveProperty('name', 'email');
expect(inputProps).toHaveProperty('onChange');
});
it('includes error when validation fails', async () => {
const validator = () => 'Error message';
const {result} = renderHook(() => useForm({
defaultFormData: {field: 'value'},
submitter: jest.fn(),
}));
act(() => {
result.current.getInputProps({name: 'field', validator});
});
// Trigger validation
act(() => {
result.current.handleInputEvent({
preventDefault: jest.fn(),
target: {type: 'text', value: 'x', name: 'field', dataset: {}},
});
});
const [inputProps] = result.current.getInputProps({name: 'field', validator});
await waitFor(() => {
expect(inputProps.error).toBe('Error message');
});
});
it('applies URL validator for url type', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {url: ''},
submitter: jest.fn(),
}));
const [inputProps, inputAttrs] = result.current.getInputProps({name: 'url', type: 'url'});
expect(inputProps.type).toBe('url');
});
});
describe('getCustomProps', () => {
it('returns props for custom components', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {tags: ['tag1', 'tag2']},
submitter: jest.fn(),
}));
const [customProps, attrs] = result.current.getCustomProps({
name: 'tags',
type: 'array',
});
expect(customProps).toHaveProperty('disabled', false);
expect(customProps).toHaveProperty('value', ['tag1', 'tag2']);
expect(customProps).toHaveProperty('onChange');
expect(customProps).toHaveProperty('data-path', 'tags');
});
it('initializes undefined array fields to empty array', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {},
submitter: jest.fn(),
}));
act(() => {
result.current.getCustomProps({name: 'newArray', type: 'array'});
});
expect(result.current.formData.newArray).toEqual([]);
});
});
describe('getSelectionProps', () => {
it('returns props for dropdown elements', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {selected: 'option1'},
submitter: jest.fn(),
}));
const [selectionProps] = result.current.getSelectionProps({name: 'selected'});
expect(selectionProps).toHaveProperty('disabled', false);
expect(selectionProps).toHaveProperty('value', 'option1');
expect(selectionProps).toHaveProperty('onChange');
});
it('onChange handler extracts value from event correctly', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {selected: 'option1'},
submitter: jest.fn(),
}));
const [selectionProps] = result.current.getSelectionProps({name: 'selected'});
act(() => {
// Semantic UI dropdown passes (event, {value})
selectionProps.onChange({}, {value: 'option2'});
});
expect(result.current.formData.selected).toBe('option2');
});
});
});
describe('edge cases and error handling', () => {
it('logs error when both clearOnSuccess and fetchOnSuccess are true', () => {
renderHook(() => useForm({
fetcher: jest.fn(),
defaultFormData: {},
submitter: jest.fn(),
clearOnSuccess: true,
fetchOnSuccess: true,
}));
expect(consoleErrorSpy).toHaveBeenCalledWith('Cannot use both clearOnSuccess and fetchOnSuccess!');
});
it('logs error when fetchOnSuccess is true without fetcher', () => {
renderHook(() => useForm({
defaultFormData: {},
submitter: jest.fn(),
fetchOnSuccess: true,
}));
expect(consoleErrorSpy).toHaveBeenCalledWith('Cannot fetchOnSuccess without fetcher!');
});
it('handles empty defaultFormData', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {},
submitter: jest.fn(),
}));
expect(result.current.formData).toEqual({});
expect(result.current.dirty).toBe(false);
});
it('creates path when setting value on undefined nested path', () => {
const {result} = renderHook(() => useForm({
defaultFormData: {},
submitter: jest.fn(),
}));
act(() => {
result.current.setValue('deeply.nested.value', 'test');
});
expect(result.current.formData.deeply.nested.value).toBe('test');
});
});
});
describe('commaSeparatedValidator', () => {
it('returns null for valid comma-separated string', () => {
expect(commaSeparatedValidator('a,b,c')).toBeUndefined();
});
it('returns error when string ends with comma', () => {
expect(commaSeparatedValidator('a,b,')).toBe('Cannot end with comma');
});
it('returns error when string starts with comma', () => {
expect(commaSeparatedValidator(',a,b')).toBe('Cannot start with comma');
});
it('returns error for non-string input', () => {
expect(commaSeparatedValidator(123)).toBe('Expected a string');
expect(commaSeparatedValidator(null)).toBe('Expected a string');
});
});
describe('Form Components', () => {
const createMockForm = (formData = {}) => {
const mockForm = {
disabled: false,
formData,
getInputProps: jest.fn(({name, path, validator, type, required, onChange}) => {
const p = path || name;
return [{
type: type || 'text',
disabled: false,
value: formData[p] || '',
name,
onChange: jest.fn(),
error: null,
required: required ? null : undefined,
'data-path': p,
}, {valid: true, path: p, localSetValue: jest.fn()}];
}),
getCustomProps: jest.fn(({name, path}) => {
const p = path || name;
return [{
disabled: false,
value: formData[p],
onChange: jest.fn(),
'data-path': p,
}, {valid: true, path: p, localSetValue: jest.fn()}];
}),
onSubmit: jest.fn(),
};
return mockForm;
};
describe('InputForm', () => {
it('renders with label', () => {
const form = createMockForm({username: ''});
renderWithProviders(
<InputForm
form={form}
name="username"
label="Username"
/>
);
expect(screen.getByText('Username')).toBeInTheDocument();
});
it('shows required asterisk when required', () => {
const form = createMockForm({email: ''});
renderWithProviders(
<InputForm
form={form}
name="email"
label="Email"
required={true}
/>
);
expect(form.getInputProps).toHaveBeenCalledWith(
expect.objectContaining({required: true})
);
});
it('passes placeholder to input', () => {
const form = createMockForm({name: ''});
renderWithProviders(
<InputForm
form={form}
name="name"
label="Name"
placeholder="Enter name"
/>
);
expect(screen.getByPlaceholderText('Enter name')).toBeInTheDocument();
});
it('respects disabled prop', () => {
const form = createMockForm({field: ''});
renderWithProviders(
<InputForm
form={form}
name="field"
label="Field"
disabled={true}
/>
);
const input = screen.getByRole('textbox');
expect(input).toBeDisabled();
});
});
describe('NumberInputForm', () => {
it('renders as number input', () => {
const form = createMockForm({count: 0});
renderWithProviders(
<NumberInputForm
form={form}
name="count"
label="Count"
/>
);
expect(form.getInputProps).toHaveBeenCalledWith(
expect.objectContaining({type: 'number'})
);
});
it('applies min and max constraints', () => {
const form = createMockForm({value: 5});
renderWithProviders(
<NumberInputForm
form={form}
name="value"
label="Value"
min={0}
max={100}
/>
);
const input = screen.getByRole('spinbutton');
expect(input).toHaveAttribute('min', '0');
expect(input).toHaveAttribute('max', '100');
});
});
describe('UrlInput', () => {
it('renders URL input with label', () => {
const form = createMockForm({url: ''});
renderWithProviders(
<UrlInput form={form}/>
);
expect(screen.getByText('URL')).toBeInTheDocument();
});
it('applies URL type', () => {
const form = createMockForm({url: ''});
renderWithProviders(
<UrlInput form={form}/>
);
expect(form.getInputProps).toHaveBeenCalledWith(
expect.objectContaining({type: 'url'})
);
});
it('is required by default', () => {
const form = createMockForm({url: ''});
renderWithProviders(
<UrlInput form={form}/>
);
expect(form.getInputProps).toHaveBeenCalledWith(
expect.objectContaining({required: true})
);
});
});
describe('ToggleForm', () => {
it('renders toggle with label', () => {
const form = createMockForm({enabled: false});
renderWithProviders(
<ToggleForm
form={form}
name="enabled"
label="Enable Feature"
/>
);
expect(screen.getByText('Enable Feature')).toBeInTheDocument();
});
it('renders with checkbox input', () => {
const form = createMockForm({active: true});
renderWithProviders(
<ToggleForm
form={form}
name="active"
label="Active"
/>
);
expect(screen.getByRole('checkbox')).toBeInTheDocument();
});
});
describe('UrlsTextarea', () => {
it('renders textarea for multiple URLs', () => {
const form = createMockForm({urls: ''});
renderWithProviders(
<UrlsTextarea form={form}/>
);
expect(screen.getByPlaceholderText('Enter one URL per line')).toBeInTheDocument();
});
it('calls getInputProps with correct name', () => {
const form = createMockForm({urls: ''});
renderWithProviders(
<UrlsTextarea form={form} name="urls"/>
);
expect(form.getInputProps).toHaveBeenCalledWith(
expect.objectContaining({name: 'urls'})
);
});
});
});

48
app/src/setupTests.js Normal file
View file

@ -0,0 +1,48 @@
// Jest setup file for React Testing Library
// This file is automatically loaded by Create React App before running tests
import '@testing-library/jest-dom';
// Mock window.matchMedia (used by Semantic UI Media components)
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
// Mock IntersectionObserver (if needed for lazy loading)
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
takeRecords() {
return [];
}
unobserve() {}
};
// Suppress console errors during tests (optional - uncomment if needed)
// const originalError = console.error;
// beforeAll(() => {
// console.error = (...args) => {
// if (
// typeof args[0] === 'string' &&
// args[0].includes('Warning: ReactDOM.render')
// ) {
// return;
// }
// originalError.call(console, ...args);
// };
// });
//
// afterAll(() => {
// console.error = originalError;
// });

292
app/src/test-utils.js Normal file
View file

@ -0,0 +1,292 @@
/**
* Test utilities for React Testing Library
* Provides custom render functions with necessary context providers
*/
import React from 'react';
import {render} from '@testing-library/react';
import {BrowserRouter} from 'react-router-dom';
import {MediaContextProvider, ThemeContext} from './contexts/contexts';
/**
* Custom render function that wraps components with necessary providers
* @param {React.Component} ui - Component to render
* @param {Object} options - Render options
* @param {boolean} options.inverted - Whether to use dark theme
* @param {Object} options.themeContext - Custom theme context values
* @param {boolean} options.withMedia - Include MediaContextProvider (default: false)
* @param {Object} options.renderOptions - Additional React Testing Library options
*/
export function renderWithProviders(
ui,
{
inverted = false,
themeContext = {},
withMedia = false,
...renderOptions
} = {}
) {
const defaultThemeContext = {
inverted,
setInverted: jest.fn(),
// Theme components use different properties:
i: inverted ? {inverted: true} : {inverted: undefined}, // For Semantic UI elements (Segment, Form, etc.)
s: inverted ? {style: {backgroundColor: '#1B1C1D', color: '#dddddd'}} : {}, // For style inversion
t: inverted ? {style: {color: '#eeeeee'}} : {}, // For text color inversion (Header, etc.)
theme: inverted ? 'dark' : 'light',
...themeContext
};
function Wrapper({children}) {
const content = (
<BrowserRouter>
<ThemeContext.Provider value={defaultThemeContext}>
{children}
</ThemeContext.Provider>
</BrowserRouter>
);
// Only wrap with MediaContextProvider if explicitly requested
// (since it requires window.matchMedia which can be tricky in tests)
if (withMedia) {
return <MediaContextProvider>{content}</MediaContextProvider>;
}
return content;
}
return render(ui, {wrapper: Wrapper, ...renderOptions});
}
/**
* Creates a mock collection metadata object for testing
*/
export function createMockMetadata(kind = 'domain', overrides = {}) {
return {
kind,
columns: [
{key: 'domain', label: 'Domain', sortable: true},
{key: 'archive_count', label: 'Archives', sortable: true, align: 'right'},
{key: 'size', label: 'Size', sortable: true, align: 'right', format: 'bytes'},
{key: 'tag_name', label: 'Tag', sortable: true},
{key: 'actions', label: 'Manage', sortable: false, type: 'actions'},
],
fields: [
{key: 'directory', label: 'Directory', type: 'text', placeholder: 'Optional directory path'},
{key: 'tag_name', label: 'Tag', type: 'tag', placeholder: 'Select or create tag', depends_on: 'directory'},
{key: 'description', label: 'Description', type: 'textarea', placeholder: 'Optional description'},
],
routes: {
list: '/archive/domains',
edit: '/archive/domain/:id/edit',
search: '/archive',
},
messages: {
no_directory: 'Set a directory to enable tagging',
tag_will_move: 'Tagging will move files to a new directory'
},
...overrides
};
}
/**
* Creates a mock domain collection object for testing
*/
export function createMockDomain(overrides = {}) {
return {
id: 1,
domain: 'example.com',
archive_count: 42,
size: 1024000,
tag_name: null,
directory: '',
can_be_tagged: false,
description: '',
...overrides
};
}
/**
* Creates multiple mock domains for list testing
*/
export function createMockDomains(count = 3) {
return Array.from({length: count}, (_, i) => createMockDomain({
id: i + 1,
domain: `example${i + 1}.com`,
archive_count: (i + 1) * 10,
size: (i + 1) * 1000000,
}));
}
/**
* Mock fetch implementation for API calls
*/
export function mockFetch(data, options = {}) {
const {
ok = true,
status = 200,
delay = 0,
} = options;
return jest.fn(() =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
ok,
status,
json: async () => data,
text: async () => JSON.stringify(data),
});
}, delay);
})
);
}
/**
* Mock API error response
*/
export function mockFetchError(error = 'An error occurred', status = 400) {
return jest.fn(() =>
Promise.resolve({
ok: false,
status,
json: async () => ({error}),
text: async () => error,
})
);
}
/**
* Wait for async updates to complete
* Useful for testing loading states
*/
export async function waitForLoadingToFinish() {
const {waitFor} = await import('@testing-library/react');
await waitFor(() => {}, {timeout: 100});
}
/**
* Test helper to render components in dark mode
*
* Usage:
* renderInDarkMode(<MyComponent />)
*
* Verify inverted styling is applied:
* const element = container.querySelector('.ui.segment.inverted');
* expect(element).toBeInTheDocument();
*/
export function renderInDarkMode(ui, options = {}) {
return renderWithProviders(ui, {
inverted: true,
...options
});
}
/**
* Test helper to render components in light mode
* (This is the default, but provided for explicitness in tests)
*/
export function renderInLightMode(ui, options = {}) {
return renderWithProviders(ui, {
inverted: false,
...options
});
}
/**
* Helper to check if an element has theme-aware (inverted) styling
* Returns true if the element has the 'inverted' class
*
* Usage:
* const segment = container.querySelector('.ui.segment');
* expect(hasInvertedStyling(segment)).toBe(true);
*/
export function hasInvertedStyling(element) {
return element && element.classList.contains('inverted');
}
/**
* Creates a test-friendly form object using real useForm hook
*
* This uses the actual useForm implementation, making tests more reliable.
* The form starts in a "ready" state with the provided data.
*
* Usage:
* const form = createTestForm({domain: 'test.com', directory: '/path'});
* render(<CollectionEditForm form={form} metadata={metadata} />);
*
* // With overrides:
* const form = createTestForm(data, {overrides: {loading: true, disabled: true}});
*/
export function createTestForm(initialData = {}, config = {}) {
// Return a plain object that mimics the useForm interface without actual React hooks
// This avoids async state updates that cause act() warnings in tests
const _ = require('lodash');
const formData = {...initialData};
const setValue = (path, newValue) => {
_.set(formData, path, newValue);
};
const form = {
formData,
ready: true,
loading: false,
disabled: false,
dirty: false,
errors: {},
// Methods that update formData directly
patchFormData: jest.fn((updates) => {
Object.assign(formData, updates);
}),
reset: jest.fn(() => {
Object.keys(formData).forEach(key => delete formData[key]);
Object.assign(formData, initialData);
}),
onSubmit: config.submitter || jest.fn(async () => initialData),
// Input helpers
setError: jest.fn(),
setValidator: jest.fn(),
setValidValue: jest.fn(),
setRequired: jest.fn(),
// Property getter for field values
get: (path) => _.get(formData, path),
setValue: jest.fn((path, value) => setValue(path, value)),
// getCustomProps - mimics the real useForm method
getCustomProps: ({name, path, required = false, type = 'text'}) => {
path = path || name;
const value = _.get(formData, path);
const inputProps = {
type,
disabled: form.disabled,
value: value !== undefined ? value : (type === 'array' ? [] : null),
onChange: (newValue) => {
setValue(path, newValue);
},
'data-path': path,
};
const inputAttrs = {
valid: true,
path,
localSetValue: (newValue) => setValue(path, newValue),
};
return [inputProps, inputAttrs];
},
};
// Apply any overrides
if (config.overrides) {
Object.assign(form, config.overrides);
}
return form;
}
// Re-export everything from React Testing Library
export * from '@testing-library/react';
// Override the default render with our custom one
export {renderWithProviders as render};

View file

@ -12,4 +12,6 @@
%wrolpi ALL= NOPASSWD:/usr/bin/systemctl stop wrolpi-app.service
%wrolpi ALL= NOPASSWD:/usr/bin/systemctl start wrolpi-app.service
%wrolpi ALL= NOPASSWD:/opt/wrolpi/scripts/import_map.sh
%wrolpi ALL= NOPASSWD:/opt/wrolpi/upgrade.sh *
%wrolpi ALL= NOPASSWD:/usr/bin/systemctl start wrolpi-upgrade.service
%wrolpi ALL= NOPASSWD:/usr/sbin/shutdown

View file

@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WROLPi - Upgrade in Progress</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
color: #333;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8f0 50%, #dde4ed 100%);
}
.container {
text-align: center;
padding: 2rem;
max-width: 500px;
width: 90%;
}
.logo {
font-size: 3rem;
margin-bottom: 1rem;
}
h1 {
color: #5829bb;
font-size: 1.8rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.subtitle {
color: #666;
font-size: 1rem;
margin-bottom: 2rem;
}
.spinner-container {
margin: 2rem 0;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #ddd;
border-top: 4px solid #4CAF50;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.status {
font-size: 1.1rem;
margin: 1.5rem 0;
min-height: 1.5em;
}
.elapsed {
color: #666;
font-size: 0.9rem;
margin-top: 1rem;
}
.warning {
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 8px;
padding: 1rem;
margin-top: 2rem;
font-size: 0.9rem;
color: #b38600;
}
.success {
color: #4CAF50;
}
.check-icon {
font-size: 3rem;
color: #4CAF50;
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.5); }
to { opacity: 1; transform: scale(1); }
}
.hidden {
display: none;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">WROLPi</div>
<h1>Upgrade in Progress</h1>
<p class="subtitle">Please wait while your system is being upgraded</p>
<div class="spinner-container" id="spinnerContainer">
<div class="spinner"></div>
</div>
<div class="spinner-container hidden" id="successContainer">
<div class="check-icon">&#10003;</div>
</div>
<p class="status" id="status">Checking upgrade status...</p>
<p class="elapsed" id="elapsed">Elapsed: 0:00</p>
<div class="warning">
<strong>Do not power off</strong> your device or close this window during the upgrade.
</div>
</div>
<script>
const startTime = Date.now();
let pollInterval;
let elapsedInterval;
function updateElapsed() {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
document.getElementById('elapsed').textContent =
`Elapsed: ${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function showSuccess() {
document.getElementById('spinnerContainer').classList.add('hidden');
document.getElementById('successContainer').classList.remove('hidden');
}
async function checkApiStatus() {
try {
const response = await fetch('/api/status', {
method: 'GET',
headers: { 'Accept': 'application/json' },
// Short timeout to avoid long waits
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
// API is back online!
clearInterval(pollInterval);
clearInterval(elapsedInterval);
showSuccess();
document.getElementById('status').innerHTML =
'<span class="success">Upgrade complete! Redirecting...</span>';
// Redirect to home after a short delay
setTimeout(() => {
window.location.href = '/';
}, 2000);
}
} catch (e) {
// API is still down or timeout - this is expected during upgrade
document.getElementById('status').textContent = 'Upgrade in progress...';
}
}
// Update elapsed time every second
elapsedInterval = setInterval(updateElapsed, 1000);
// Check API status every 3 seconds
pollInterval = setInterval(checkApiStatus, 3000);
// Initial check
checkApiStatus();
</script>
</body>
</html>

View file

@ -0,0 +1,11 @@
[Unit]
Description=WROLPi Upgrade Service
After=network.target
[Service]
Type=oneshot
EnvironmentFile=/tmp/wrolpi-upgrade.env
ExecStart=/opt/wrolpi/upgrade.sh -b ${BRANCH}
User=root
StandardOutput=journal
StandardError=journal

View file

@ -1,5 +1,22 @@
# Nginx config for the WROLPi services.
#
# Maintenance page for upgrades
#
location = /maintenance.html {
alias /var/www/maintenance.html;
}
#
# Error pages
#
location /error/ {
alias /var/www/;
internal;
}
#
# App
#

38
main.py
View file

@ -13,6 +13,7 @@ from modules.inventory import init_inventory
from modules.inventory.common import import_inventories_config
from modules.videos.lib import import_channels_config, get_videos_downloader_config
from wrolpi import flags, admin
from modules.archive.lib import import_domains_config
from wrolpi import root_api # noqa
from wrolpi import tags
from wrolpi.api_utils import api_app, perpetual_signal
@ -244,6 +245,10 @@ async def start_single_tasks(app: Sanic):
with suppress(Exception):
import_channels_config()
logger.debug('channels config imported')
# Domains config import
with suppress(Exception):
import_domains_config()
logger.debug('domains config imported')
with suppress(Exception):
import_inventories_config()
logger.debug('inventories config imported')
@ -353,5 +358,38 @@ async def perpetual_start_video_missing_comments_download():
logger.debug('Waiting for downloads to be enabled before downloading comments...')
@perpetual_signal(sleep=3600) # Check hourly
async def perpetual_check_for_updates():
"""
Check git remote for new commits on the current branch.
Updates shared_ctx with update info for the /api/status endpoint.
Only runs on native installs (not Docker).
"""
from wrolpi.upgrade import check_for_update
# Don't check for updates in Docker environments
if DOCKERIZED:
return
# Only check when we have internet
if not flags.have_internet.is_set():
return
try:
result = check_for_update(fetch=True)
# Store in shared_ctx.status (a manager.dict) to share across all workers.
api_app.shared_ctx.status['update_available'] = result.get('update_available', False)
api_app.shared_ctx.status['latest_commit'] = result.get('latest_commit')
api_app.shared_ctx.status['current_commit'] = result.get('current_commit')
api_app.shared_ctx.status['commits_behind'] = result.get('commits_behind', 0)
api_app.shared_ctx.status['git_branch'] = result.get('branch')
if result.get('update_available'):
logger.info(f"Update available: {result['commits_behind']} commits behind on {result['branch']}")
except Exception as e:
logger.error('Failed to check for updates', exc_info=e)
if __name__ == '__main__':
sys.exit(main())

View file

@ -8,6 +8,7 @@ from sqlalchemy import not_
from sqlalchemy.orm import Session
from wrolpi.cmd import SINGLE_FILE_BIN, CHROMIUM
from wrolpi.collections import Collection
from wrolpi.common import logger, register_modeler, register_refresh_cleanup, limit_concurrent, split_lines_by_length, \
slow_logger, get_title_from_html
from wrolpi.db import optional_session, get_db_session
@ -18,8 +19,8 @@ from wrolpi.vars import PYTEST, DOCKERIZED, DOWNLOAD_USER_AGENT
from . import lib
from .api import archive_bp # noqa
from .errors import InvalidArchive
from .lib import is_singlefile_file, request_archive, SINGLEFILE_HEADER
from .models import Archive, Domain
from .lib import is_singlefile_file, request_archive, SINGLEFILE_HEADER, get_url_from_singlefile
from .models import Archive
PRETTY_NAME = 'Archive'
@ -186,15 +187,61 @@ def model_archive(file_group: FileGroup, session: Session = None) -> Archive:
if not archive:
# Create new Archive if it doesn't exist
archive = Archive(file_group_id=file_group_id, file_group=file_group)
# Set collection_id BEFORE adding to session to avoid autoflush constraint violation
from modules.archive.lib import get_or_create_domain_collection
if file_group.url:
# URL is already set on the FileGroup
collection = get_or_create_domain_collection(session, file_group.url)
archive.collection_id = collection.id if collection else None
else:
# No URL - try to extract from singlefile
try:
url = get_url_from_singlefile(singlefile_path.read_bytes())
file_group.url = url
collection = get_or_create_domain_collection(session, url)
archive.collection_id = collection.id if collection else None
except (RuntimeError, ValueError) as e:
# Could not extract URL from singlefile
if not PYTEST:
# In production, archives must have a URL/collection
raise InvalidArchive(f'Archive has no URL and could not extract from singlefile: {e}') from e
# In tests, allow archives without collections (factory will set it later)
logger.debug(f'Could not extract URL from singlefile (test mode): {e}')
archive.collection_id = None
session.add(archive)
archive.validate()
archive.flush()
# Validate that archive path matches domain directory (efficient O(1) check)
if archive.collection_id and archive.collection:
collection = archive.collection
if collection.kind == 'domain' and collection.directory and not collection.tag_id:
# Check if archive path is under the domain's directory
archive_path = pathlib.Path(file_group.primary_path)
from wrolpi.common import get_media_directory
media_dir = get_media_directory()
collection_abs_dir = media_dir / collection.directory
try:
# Check if archive is under the collection's directory
archive_path.relative_to(collection_abs_dir)
except ValueError:
# Archive is NOT under the domain's directory - clear it
logger.warning(
f'Archive {archive.id} at {archive_path} is not under domain directory {collection.directory}. '
f'Clearing domain directory (domain is not tagged).'
)
collection.directory = None
session.flush([collection])
file_group.title = file_group.a_text = title or archive.file_group.title
file_group.d_text = contents
file_group.data = {
'id': archive.id,
'domain': archive.domain.domain if archive.domain else None,
'domain': archive.domain,
'readability_json_path': archive.readability_json_path,
'readability_path': archive.readability_path,
'readability_txt_path': archive.readability_txt_path,
@ -230,26 +277,33 @@ async def archive_modeler():
processed = 0
for processed, (file_group, archive) in enumerate(results):
# Even if indexing fails, we mark it as indexed. We won't retry indexing this.
file_group.indexed = True
with slow_logger(1, f'Modeling archive took %(elapsed)s seconds: {file_group}',
logger__=logger):
if archive:
try:
archive_id = archive.id
archive.validate()
# Successfully validated, mark as indexed
file_group.indexed = True
except Exception:
logger.error(f'Unable to validate Archive {archive_id}')
# Don't mark as indexed - will retry later
if PYTEST:
raise
else:
try:
model_archive(file_group, session=session)
# Successfully modeled, mark as indexed
file_group.indexed = True
except InvalidArchive:
# It was not a real Archive. Many HTML files will not be an Archive.
file_group.indexed = False
invalid_archives.add(file_group.id)
except Exception as e:
# Some other error occurred during modeling - don't mark as indexed so we can retry
logger.error(f'Failed to model Archive for FileGroup {file_group.id}: {e}')
if PYTEST:
raise
session.commit()
@ -267,11 +321,14 @@ async def archive_modeler():
@limit_concurrent(1)
def archive_cleanup():
with get_db_session(commit=True) as session:
# Remove any Domains without any Archives.
domain_ids = [i[0] for i in session.execute('SELECT DISTINCT domain_id FROM archive') if i[0]]
for domain in session.query(Domain):
if domain.id not in domain_ids:
session.delete(domain)
# Remove any domain Collections without any Archives.
# Get all collection_ids that have archives
collection_ids = [i[0] for i in session.execute('SELECT DISTINCT collection_id FROM archive') if i[0]]
# Find domain collections that have no archives
for collection in session.query(Collection).filter_by(kind='domain'):
if collection.id not in collection_ids:
logger.info(f'Deleting empty domain collection: {collection.name}')
session.delete(collection)
def get_title(path):

View file

@ -9,10 +9,8 @@ from sqlalchemy.orm import Session
from wrolpi.api_utils import json_response, api_app
from wrolpi.common import logger, wrol_mode_check, api_param_limiter, TRACE_LEVEL
from wrolpi.db import get_db_session
from wrolpi.errors import ValidationError
from wrolpi.events import Events
from wrolpi.files.lib import upsert_file
from wrolpi.schema import JSONErrorResponse
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
from . import lib, schema
@ -48,14 +46,6 @@ async def delete_archive(_: Request, archive_ids: str):
return response.empty()
@archive_bp.get('/domains')
@openapi.summary('Get a list of all Domains and their Archive statistics')
@openapi.response(200, schema.GetDomainsResponse, "The list of domains")
async def get_domains(_: Request):
domains = lib.get_domains()
return json_response({'domains': domains, 'totals': {'domains': len(domains)}})
archive_limit_limiter = api_param_limiter(100)

View file

@ -8,7 +8,8 @@ import pytest
import pytz
from modules.archive.lib import archive_strftime
from modules.archive.models import Archive, Domain
from modules.archive.models import Archive
from wrolpi.collections import Collection
@pytest.fixture
@ -58,21 +59,27 @@ def archive_factory(test_session, archive_directory, make_files_structure, image
})
if domain:
domain = test_session.query(Domain).filter_by(domain=domain).one_or_none()
# Find or create domain collection
collection = test_session.query(Collection).filter_by(
name=domain,
kind='domain'
).one_or_none()
if not collection:
collection = Collection(
name=domain,
kind='domain',
directory=None, # Domain collections are unrestricted
)
test_session.add(collection)
test_session.flush([collection])
else:
collection = None
screenshot_path = None
if screenshot:
screenshot_path = domain_dir / f'{timestamp}_{title}.png'
screenshot_path.write_bytes(image_bytes_factory())
if not domain and domain_dir.name != 'NA':
domain = Domain(
domain=domain_dir.name,
directory=domain_dir,
)
test_session.add(domain)
test_session.flush([domain])
# Only add files that were created.
files = (readability_path, readability_json_path, readability_txt_path, screenshot_path, singlefile_path)
files = list(filter(None, files))
@ -82,7 +89,7 @@ def archive_factory(test_session, archive_directory, make_files_structure, image
archive.title = title
archive.file_group.download_datetime = archive.file_group.published_datetime = next(now)
archive.file_group.modification_datetime = next(now)
archive.domain = domain
archive.collection = collection
archive.validate()
for tag_name in tag_names:

View file

@ -7,7 +7,7 @@ import pathlib
import re
import shlex
import tempfile
from dataclasses import dataclass
from dataclasses import dataclass, field
from datetime import datetime
from json import JSONDecodeError
from typing import Optional, Tuple, List, Union
@ -17,22 +17,190 @@ import pytz
from sqlalchemy import asc
from sqlalchemy.orm import Session
from modules.archive.models import Domain, Archive
from modules.archive.models import Archive
from wrolpi import dates
from wrolpi.cmd import READABILITY_BIN, run_command
from wrolpi.collections import Collection
from wrolpi.common import get_media_directory, logger, extract_domain, escape_file_name, aiohttp_post, \
format_html_string, split_lines_by_length, get_html_soup, get_title_from_html, get_wrolpi_config, html_screenshot
format_html_string, split_lines_by_length, get_html_soup, get_title_from_html, get_wrolpi_config, html_screenshot, \
ConfigFile
from wrolpi.dates import now, Seconds
from wrolpi.db import get_db_session, get_db_curs, optional_session
from wrolpi.errors import UnknownArchive, InvalidOrderBy, InvalidDatetime
from wrolpi.events import Events
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
from wrolpi.tags import tag_append_sub_select_where
from wrolpi.vars import PYTEST, DOCKERIZED
logger = logger.getChild(__name__)
__all__ = ['DomainsConfig', 'domains_config', 'get_domains_config', 'save_domains_config', 'import_domains_config']
ARCHIVE_SERVICE = 'http://archive:8080'
@dataclass
class DomainsConfigValidator:
"""Validator for domains config file."""
version: int = 0
collections: List[dict] = field(default_factory=list)
class DomainsConfig(ConfigFile):
"""
Config file for Domain Collections.
This is a specialized config that manages domain collections
(Collections with kind='domain'). It maintains a domains.yaml file for
backward compatibility and user convenience.
Format:
collections:
- name: "example.com"
kind: "domain"
description: "Archives from example.com"
- name: "wikipedia.org"
kind: "domain"
Note: Domain collections can have optional directories for tagging support.
Items are managed dynamically when Archives are indexed.
"""
file_name = 'domains.yaml'
validator = DomainsConfigValidator
default_config = dict(
version=0,
collections=[],
)
# Use wider width to accommodate longer paths
width = 120
def __getitem__(self, item):
return self._config[item]
def __setitem__(self, key, value):
self._config[key] = value
@property
def collections(self) -> List[dict]:
"""Get list of collection configs."""
return self._config.get('collections', [])
def import_config(self, file: pathlib.Path = None, send_events=False):
"""Import domain collections from config file into database."""
ConfigFile.import_config(self, file, send_events)
file_str = str(self.get_relative_file())
collections_data = self._config.get('collections', [])
if not collections_data:
logger.info(f'No domain collections to import from {file_str}')
self.successful_import = True
return
logger.info(f'Importing {len(collections_data)} domain collections from {file_str}')
try:
with get_db_session(commit=True) as session:
# Track imported domain names
imported_domains = set()
# Import each domain collection, forcing kind='domain'
for idx, collection_data in enumerate(collections_data):
try:
name = collection_data.get('name')
if not name:
logger.error(f'Domain collection at index {idx} has no name, skipping')
continue
# Ensure kind is 'domain'
collection_data = collection_data.copy()
collection_data['kind'] = 'domain'
# Warn if tag_name provided without directory
tag_name = collection_data.get('tag_name')
directory = collection_data.get('directory')
if tag_name and not directory:
logger.warning(
f"Domain collection '{name}' has tag_name '{tag_name}' "
f"but no directory - tags require a directory. Tag will be ignored."
)
collection_data.pop('tag_name', None)
# Use Collection.from_config to create/update
collection = Collection.from_config(collection_data, session)
imported_domains.add(collection.name)
except Exception as e:
logger.error(f'Failed to import domain collection at index {idx}', exc_info=e)
continue
# Delete domain collections that are no longer in config
all_domain_collections = session.query(Collection).filter_by(kind='domain').all()
for collection in all_domain_collections:
if collection.name not in imported_domains:
logger.info(f'Deleting domain collection {repr(collection.name)} (no longer in config)')
session.delete(collection)
logger.info(f'Successfully imported {len(imported_domains)} domain collections from {file_str}')
self.successful_import = True
except Exception as e:
self.successful_import = False
message = f'Failed to import {file_str} config!'
logger.error(message, exc_info=e)
if send_events:
Events.send_config_import_failed(message)
raise
def dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False):
"""Dump all domain collections from database to config file."""
logger.info('Dumping domain collections to config')
with get_db_session() as session:
# Get only domain collections, ordered by name
collections = session.query(Collection).filter_by(kind='domain').order_by(Collection.name).all()
# Use to_config to export each collection
collections_data = [collection.to_config() for collection in collections]
self._config['collections'] = collections_data
logger.info(f'Dumping {len(collections_data)} domain collections to config')
self.save(file, send_events, overwrite)
# Global instance
domains_config = DomainsConfig()
def get_domains_config() -> DomainsConfig:
"""Get the global domains config instance."""
return domains_config
# Switch handler for saving domains config
@register_switch_handler('save_domains_config')
def save_domains_config():
"""Save the domains config when the switch is activated."""
domains_config.background_dump.activate_switch()
# Explicit type for activate_switch helper
save_domains_config: ActivateSwitchMethod
def import_domains_config():
"""Import domain collections from config file into database."""
logger.info('Importing domains config')
domains_config.import_config()
# Link downloads to domain collections after import
with get_db_session() as session:
link_domain_and_downloads(session)
logger.info('Importing domains config completed')
@dataclass
class ArchiveFiles:
"""Every Archive will have some of these files."""
@ -74,6 +242,25 @@ def get_domain_directory(url: str) -> pathlib.Path:
return directory
def get_archive_destination(domain_collection: 'Collection') -> pathlib.Path:
"""
Get the destination directory for archives in a domain collection.
Args:
domain_collection: The domain Collection (kind='domain')
Returns:
Path where archives should be placed. If collection has a directory,
returns that directory. Otherwise returns the default archives/<domain>/ path.
"""
if domain_collection.directory:
# Restricted collection - use collection directory
return get_media_directory() / domain_collection.directory
else:
# Unrestricted collection - use default archive/<domain>/ path
return get_archive_directory() / domain_collection.name
# File names include domain and datetime.
MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH = 200
@ -248,24 +435,223 @@ async def model_archive_result(url: str, singlefile: str, readability: dict, scr
archive = Archive.from_paths(session, *paths)
archive.file_group.download_datetime = now()
archive.url = url
archive.domain = get_or_create_domain(session, url)
archive.collection = get_or_create_domain_collection(session, url)
archive.flush()
archive.domain.flush()
return archive
def get_or_create_domain(session: Session, url) -> Domain:
def detect_domain_directory(collection: Collection, session: Session) -> Optional[pathlib.Path]:
"""
Get/create the Domain for this archive.
Detect if all archives for a domain collection share a common directory.
Args:
collection: The domain collection to analyze
session: Database session
Returns:
Path (relative to media directory) if all archives share a common directory within archive media directory.
None if archives are scattered across different directories or if collection has no archives.
"""
domain_ = extract_domain(url)
domain = session.query(Domain).filter_by(domain=domain_).one_or_none()
if not domain:
domain = Domain(domain=domain_, directory=str(get_domain_directory(url)))
session.add(domain)
if collection.kind != 'domain':
return None
# Query all archives for this domain collection
archives = session.query(Archive).filter_by(collection_id=collection.id).all()
if not archives:
# No archives yet, can't determine directory
return None
# Get all archive file paths
paths = []
for archive in archives:
if archive.file_group and archive.file_group.primary_path:
paths.append(pathlib.Path(archive.file_group.primary_path))
if not paths:
return None
# Find common ancestor directory
# Start with the first path's parent directory
common_dir = paths[0].parent
# Check if all other paths are under this directory
for path in paths[1:]:
try:
# Check if path is relative to common_dir
path.relative_to(common_dir)
except ValueError:
# Path is not under common_dir, find the common ancestor
# Walk up until we find a common parent
while common_dir != common_dir.parent: # Stop at root
try:
path.relative_to(common_dir)
break # Found common ancestor
except ValueError:
common_dir = common_dir.parent
# Check if common directory is within the media directory
media_dir = get_media_directory()
try:
relative_path = common_dir.relative_to(media_dir)
except ValueError:
# Common directory is outside media directory
return None
# Check if it's within the archive directory structure
archive_base = get_archive_directory()
try:
archive_base.relative_to(media_dir) # Verify archive_base is under media_dir
common_dir.relative_to(archive_base.parent) # Verify common_dir is under archive structure
except ValueError:
# Not in the archive directory structure
return None
logger.debug(f'Detected directory for domain {collection.name}: {relative_path}')
return relative_path
def update_domain_directories(session: Session = None) -> int:
"""
One-time update to detect and set directories for existing domain collections.
This should be run once to fix domain collections that were created without directories
but have all their archives in a common location.
Args:
session: Database session
Returns:
Number of domain collections updated
"""
if session is None:
with get_db_session(commit=True) as session:
return update_domain_directories(session)
# Find all domain collections without directories
collections = session.query(Collection).filter_by(kind='domain', directory=None).all()
updated_count = 0
for collection in collections:
detected_dir = detect_domain_directory(collection, session)
if detected_dir:
collection.directory = detected_dir
session.flush([collection])
updated_count += 1
logger.info(f'Updated domain {collection.name} with directory: {detected_dir}')
session.commit()
logger.info(f'Updated {updated_count} domain collection(s) with auto-detected directories')
return updated_count
def get_or_create_domain_collection(session: Session, url, directory: pathlib.Path = None) -> Collection:
"""
Get or create the domain Collection for this archive.
Args:
session: Database session
url: URL of the archive
directory: Optional directory to restrict this domain collection.
If None, creates unrestricted collection (default).
If provided, archives will be placed in this directory and collection can be tagged.
Returns:
Collection with kind='domain' for the domain extracted from the URL
"""
domain_name = extract_domain(url)
# Try to find existing domain collection
collection = session.query(Collection).filter_by(
name=domain_name,
kind='domain'
).one_or_none()
if not collection:
# Create new domain collection
collection = Collection(
name=domain_name,
kind='domain',
directory=directory, # Can be None (unrestricted) or a Path (restricted)
)
session.add(collection)
session.flush()
return domain
# Trigger domain config save for new domain
save_domains_config.activate_switch()
if directory:
logger.info(f'Created domain collection with directory: {domain_name} -> {directory}')
else:
logger.info(f'Created unrestricted domain collection: {domain_name}')
# Auto-detect directory if not explicitly set and collection doesn't have one
if not directory and not collection.directory:
detected_dir = detect_domain_directory(collection, session)
if detected_dir:
collection.directory = detected_dir
session.flush()
# Trigger domain config save when directory is auto-detected
save_domains_config.activate_switch()
logger.info(f'Auto-detected directory for domain {domain_name}: {detected_dir}')
return collection
def link_domain_and_downloads(session: Session):
"""Associate any Download related to a Domain Collection.
Downloads are linked to Collections (via collection_id).
This function finds Domain Collections and links their Downloads.
Matching criteria:
- Recurring downloads whose destination is within the domain collection's directory (including subdirectories)
- RSS downloads with sub_downloader='archive' (matched by URL domain)
"""
from wrolpi.downloader import Download
from wrolpi.collections.models import Collection
# Only Downloads with a frequency can be a Collection Download.
downloads = list(session.query(Download).filter(Download.frequency.isnot(None)).all())
# Get domain collections that have a directory
domain_collections = session.query(Collection).filter(
Collection.kind == 'domain',
Collection.directory.isnot(None)
).all()
need_commit = False
# Match downloads by destination directory (including subdirectories)
downloads_with_destination = [d for d in downloads if (d.settings or {}).get('destination')]
for collection in domain_collections:
# Ensure directory ends with / for proper prefix matching
directory = str(collection.directory)
directory_prefix = directory if directory.endswith('/') else directory + '/'
for download in downloads_with_destination:
dest = download.settings['destination']
# Match if destination equals directory or is a subdirectory
if not download.collection_id and (dest == directory or dest.startswith(directory_prefix)):
download.collection_id = collection.id
need_commit = True
# Match RSS downloads with archive sub_downloader by URL domain
rss_archive_downloads = [
d for d in downloads
if d.downloader == 'rss' and d.sub_downloader == 'archive' and not d.collection_id
]
for download in rss_archive_downloads:
# Extract domain from the RSS URL and find matching domain collection
domain_name = extract_domain(download.url)
collection = session.query(Collection).filter_by(
name=domain_name,
kind='domain'
).one_or_none()
if collection:
download.collection_id = collection.id
need_commit = True
if need_commit:
session.commit()
SINGLEFILE_URL_EXTRACTOR = re.compile(r'Page saved with SingleFile \s+url: (http.+?)\n')
@ -463,18 +849,86 @@ def is_singlefile_file(path: pathlib.Path) -> bool:
def get_domains():
"""
Get all domain collections with their archive statistics.
This is a thin wrapper around Collection queries that adds archive-specific statistics.
Returns a list of dicts with collection id, domain name, url_count, and total size.
"""
from sqlalchemy import func, BigInteger
from wrolpi.files.models import FileGroup
with get_db_session() as session:
# Query all domain collections with archive statistics in a single query
# This uses ORM for better maintainability while keeping performance
query = (
session.query(
Collection.id,
Collection.name.label('domain'),
func.count(Archive.id).label('url_count'),
func.sum(FileGroup.size).cast(BigInteger).label('size')
)
.outerjoin(Archive, Collection.id == Archive.collection_id)
.outerjoin(FileGroup, FileGroup.id == Archive.file_group_id)
.filter(Collection.kind == 'domain')
.group_by(Collection.id, Collection.name)
.order_by(Collection.name)
)
domains = [
{
'id': row.id,
'domain': row.domain,
'url_count': row.url_count or 0,
'size': row.size or 0,
}
for row in query.all()
]
return domains
def get_domain(domain_id: int) -> dict:
"""
Get a single domain collection by ID with its archive statistics.
Returns a dict with collection details including id, domain name, url_count, size,
tag_name, directory (relative to media directory), and description.
Raises UnknownArchive if domain not found.
"""
# Get the collection using find_by_id
try:
collection = Collection.find_by_id(domain_id)
except Exception:
# Collection.find_by_id raises UnknownCollection, but we want UnknownArchive
raise UnknownArchive(f"Domain collection with ID {domain_id} not found")
if collection.kind != 'domain':
raise UnknownArchive(f"Collection {domain_id} is not a domain")
# Get base domain data from __json__()
domain_data = collection.__json__()
# Get archive statistics for this domain
with get_db_curs() as curs:
stmt = '''
SELECT domains.domain AS domain, COUNT(a.id) AS url_count, SUM(fg.size)::BIGINT AS size
FROM domains
LEFT JOIN archive a on domains.id = a.domain_id
SELECT COUNT(a.id) AS url_count, SUM(fg.size)::BIGINT AS size
FROM archive a
LEFT JOIN file_group fg on fg.id = a.file_group_id
GROUP BY domains.domain
ORDER BY domains.domain \
WHERE a.collection_id = %(domain_id)s
'''
curs.execute(stmt)
domains = [dict(i) for i in curs.fetchall()]
return domains
curs.execute(stmt, {'domain_id': domain_id})
stats = dict(curs.fetchone())
# Enhance with archive stats
domain_data['url_count'] = stats['url_count'] or 0
domain_data['size'] = stats['size'] or 0
# Use 'domain' key for domain collections instead of 'name'
domain_data['domain'] = domain_data.pop('name')
return domain_data
ARCHIVE_ORDERS = {
@ -556,7 +1010,9 @@ def search_archives(search_str: str, domain: str, limit: int, offset: int, order
if domain:
params['domain'] = domain
wheres.append('a.domain_id = (select id from domains where domains.domain = %(domain)s)')
# Use LIMIT 1 to handle potential duplicate domain collections with the same name
wheres.append(
"a.collection_id = (select id from collection where collection.name = %(domain)s and collection.kind = 'domain' LIMIT 1)")
select_columns = f", {select_columns}" if select_columns else ""
wheres = '\n AND '.join(wheres)
@ -582,13 +1038,38 @@ def search_archives(search_str: str, domain: str, limit: int, offset: int, order
@optional_session
async def search_domains_by_name(name: str, limit: int = 5, session: Session = None) -> List[Domain]:
domains = session.query(Domain) \
.filter(Domain.domain.ilike(f'%{name}%')) \
.order_by(asc(Domain.domain)) \
async def search_domains_by_name(name: str, limit: int = 5, session: Session = None) -> List[dict]:
"""
Search for domain collections by name.
Args:
name: Search string to match against collection names
limit: Maximum number of results to return
session: Database session
Returns:
List of domain dicts matching the search (in old Domain format for backward compatibility)
"""
collections = session.query(Collection) \
.filter(Collection.kind == 'domain') \
.filter(Collection.name.ilike(f'%{name}%')) \
.order_by(asc(Collection.name)) \
.limit(limit) \
.all()
return domains
# Convert to old Domain format for backward compatibility
from wrolpi.common import get_relative_to_media_directory
archive_dir = get_archive_directory()
return [
{
'id': c.id,
'domain': c.name,
# Domain collections can have explicit directories or use default archive path
'directory': get_relative_to_media_directory(c.directory) if c.directory else str(
(archive_dir / c.name).relative_to(get_media_directory())),
}
for c in collections
]
async def html_to_readability(html: str | bytes, url: str, timeout: int = 120):

View file

@ -4,24 +4,23 @@ import re
from typing import Iterable, List, Optional
import pytz
from sqlalchemy import Column, Integer, String, ForeignKey, BigInteger
from sqlalchemy.orm import relationship, Session, validates
from sqlalchemy.orm.collections import InstrumentedList
from sqlalchemy import Column, Integer, ForeignKey, BigInteger
from sqlalchemy.orm import relationship, Session
from wrolpi import dates
from wrolpi.collections import Collection
from wrolpi.common import ModelHelper, Base, logger, get_title_from_html, get_wrolpi_config, get_media_directory
from wrolpi.dates import now
from wrolpi.db import optional_session
from wrolpi.errors import UnknownArchive
from wrolpi.files.models import FileGroup
from wrolpi.media_path import MediaPathType
from wrolpi.tags import TagFile
from wrolpi.vars import PYTEST
from .errors import InvalidArchive
logger = logger.getChild(__name__)
__all__ = ['Archive', 'Domain']
__all__ = ['Archive']
MATCH_URL = re.compile(r'^\s+?url:\s+?(http.*)', re.MULTILINE)
MATCH_DATE = re.compile(r'^\s+?saved date:\s+?(.*)', re.MULTILINE)
@ -31,17 +30,33 @@ class Archive(Base, ModelHelper):
__tablename__ = 'archive'
id = Column(Integer, primary_key=True)
domain_id = Column(Integer, ForeignKey('domains.id'))
domain = relationship('Domain', primaryjoin='Archive.domain_id==Domain.id')
collection_id = Column(Integer, ForeignKey('collection.id', ondelete='CASCADE'))
collection: Collection = relationship('Collection', primaryjoin='Archive.collection_id==Collection.id')
file_group_id = Column(BigInteger, ForeignKey('file_group.id', ondelete='CASCADE'), unique=True, nullable=False)
file_group: FileGroup = relationship('FileGroup')
def __repr__(self):
if self.domain:
domain_name = self.domain if self.collection else None
if domain_name:
return f'<Archive id={self.id} url={self.file_group.url} singlefile={repr(str(self.singlefile_path))} ' \
f'domain={self.domain.domain}>'
f'domain={domain_name}>'
return f'<Archive id={self.id} url={self.file_group.url} singlefile={repr(str(self.singlefile_path))}>'
@property
def domain(self) -> str | None:
"""
Get the domain name for this Archive.
This property provides backward compatibility by returning the Collection's name
(which is the domain string for domain collections).
Returns:
The domain string (e.g., "example.com") or None if no collection
"""
if self.collection and self.collection.kind == 'domain':
return self.collection.name
return None
@staticmethod
@optional_session
def get_by_id(id_: int, session: Session = None) -> Optional['Archive']:
@ -116,14 +131,14 @@ class Archive(Base, ModelHelper):
self.file_group.delete()
session = Session.object_session(self)
collection = self.collection
session.delete(self)
if self.domain:
# Delete a domain if it has no Archives.
try:
next(i.id for i in self.domain.archives)
except StopIteration:
self.domain.delete()
if collection and collection.kind == 'domain':
# Delete a domain collection if it has no Archives.
remaining_archives = session.query(Archive).filter_by(collection_id=collection.id).count()
if remaining_archives == 0:
session.delete(collection)
@property
def history(self) -> Iterable[Base]:
@ -299,16 +314,16 @@ class Archive(Base, ModelHelper):
logger.error(f'Could not get archive date from singlefile {path}', exc_info=e)
def apply_domain(self):
"""Get the domain from the URL."""
from modules.archive.lib import get_or_create_domain
domain = None
"""Get the domain collection from the URL."""
from modules.archive.lib import get_or_create_domain_collection
collection = None
if self.file_group.url:
session = Session.object_session(self)
if not session:
raise ValueError('No session found!')
domain = get_or_create_domain(session, self.file_group.url)
# Clear domain if the URL is missing.
self.domain_id = domain.id if domain else None
collection = get_or_create_domain_collection(session, self.file_group.url)
# Clear collection if the URL is missing.
self.collection_id = collection.id if collection else None
def apply_singlefile_title(self):
"""Get the title from the Singlefile, if it's missing."""
@ -363,39 +378,23 @@ class Archive(Base, ModelHelper):
"""The location where this Archive can be viewed in the UI."""
return f'/archive/{self.id}'
class Domain(Base, ModelHelper):
__tablename__ = 'domains' # plural to avoid conflict
id = Column(Integer, primary_key=True)
domain = Column(String, nullable=False)
directory = Column(MediaPathType)
archives: InstrumentedList = relationship('Archive', primaryjoin='Archive.domain_id==Domain.id')
def __repr__(self):
return f'<Domain id={self.id} domain={self.domain} directory={self.directory}>'
def delete(self):
session = Session.object_session(self)
session.execute('DELETE FROM archive WHERE domain_id=:id', dict(id=self.id))
session.query(Domain).filter_by(id=self.id).delete()
@validates('domain')
def validate_domain(self, key, value: str):
if not isinstance(value, str):
raise ValueError('Domain must be a string')
if len(value.split('.')) < 2:
raise ValueError(f'Domain must contain at least one "." domain={repr(value)}')
return value
@property
def download_directory(self) -> pathlib.Path:
"""
Get the download directory for this Archive based on its domain and date.
This uses the configured archive_destination template with variables:
- domain: the domain name from the Collection
- year, month, day: current date
Returns:
Absolute path where new archives for this domain should be downloaded
"""
archive_destination = get_wrolpi_config().archive_destination
now_ = now()
variables = dict(
domain=self.domain,
domain=self.domain or 'unknown',
year=now_.year,
month=now_.month,
day=now_.day,

View file

@ -15,18 +15,6 @@ class ArchiveDict:
title: str
@dataclass
class DomainDict:
domain: str
url_count: int
size: int
@dataclass
class GetDomainsResponse:
domains: List[DomainDict]
@dataclass
class ArchiveSearchRequest:
search_str: Optional[str] = None

View file

@ -5,14 +5,12 @@ from unittest.mock import patch
import pytest
from modules.archive import lib, Archive
from wrolpi.files.models import FileGroup
from wrolpi.common import get_relative_to_media_directory
from wrolpi.test.common import skip_circleci, skip_macos
from wrolpi.common import get_relative_to_media_directory
def check_results(test_client, data, ids):
request, response = test_client.post('/api/archive/search', content=json.dumps(data))
async def check_results(async_client, data, ids):
request, response = await async_client.post('/api/archive/search', content=json.dumps(data))
if not response.status_code == HTTPStatus.OK:
raise AssertionError(str(response.json))
@ -23,25 +21,27 @@ def check_results(test_client, data, ids):
f'{response.json["file_groups"][0]["id"]}..{response.json["file_groups"][-1]["id"]}'
def test_archives_search_order(test_session, archive_directory, archive_factory, test_client):
@pytest.mark.asyncio
async def test_archives_search_order(test_session, archive_directory, archive_factory, async_client):
"""Search using all orders."""
archive_factory('example.com', 'https://example.com/one', 'my archive', 'foo bar qux')
for order_by in lib.ARCHIVE_ORDERS:
data = {'search_str': 'foo', 'order_by': order_by}
request, response = test_client.post('/api/archive/search', content=json.dumps(data))
request, response = await async_client.post('/api/archive/search', content=json.dumps(data))
if not response.status_code == HTTPStatus.OK:
raise AssertionError(str(response.json))
data = {'search_str': None, 'order_by': order_by}
request, response = test_client.post('/api/archive/search', content=json.dumps(data))
request, response = await async_client.post('/api/archive/search', content=json.dumps(data))
if not response.status_code == HTTPStatus.OK:
raise AssertionError(str(response.json))
def test_archives_search(test_session, archive_directory, archive_factory, test_client):
@pytest.mark.asyncio
async def test_archives_search(test_session, archive_directory, archive_factory, async_client):
"""Archives can be searched by their title and their contents."""
# Search with no archives.
check_results(test_client, {'search_str': 'foo'}, [])
await check_results(async_client, {'search_str': 'foo'}, [])
archive_factory('example.com', 'https://example.com/one', 'my archive', 'foo bar qux')
archive_factory('example.com', 'https://example.com/one', 'other archive', 'foo baz qux qux')
@ -51,38 +51,38 @@ def test_archives_search(test_session, archive_directory, archive_factory, test_
# 1 and 2 contain "foo".
data = {'search_str': 'foo'}
check_results(test_client, data, [2, 1])
await check_results(async_client, data, [2, 1])
# 2 and 3 contain "baz".
data = {'search_str': 'baz'}
check_results(test_client, data, [3, 2])
await check_results(async_client, data, [3, 2])
# 1 contains "bar".
data = {'search_str': 'bar'}
check_results(test_client, data, [1, ])
await check_results(async_client, data, [1, ])
# No archives contain "huzzah"
data = {'search_str': 'huzzah'}
check_results(test_client, data, [])
await check_results(async_client, data, [])
# Only 3 contains "baz" and is in domain "example.org"
data = {'search_str': 'baz', 'domain': 'example.org'}
check_results(test_client, data, [3, ])
await check_results(async_client, data, [3, ])
# 1's title contains "my", this is ignored by Postgres.
data = {'search_str': 'my'}
check_results(test_client, data, [])
await check_results(async_client, data, [])
# 3's title contains "third".
data = {'search_str': 'third'}
check_results(test_client, data, [3, ])
await check_results(async_client, data, [3, ])
# All contents contain "qux", but they contain different amounts. They are ordered by the amount.
data = {'search_str': 'qux'}
check_results(test_client, data, [3, 2, 1])
await check_results(async_client, data, [3, 2, 1])
data = {'search_str': 'qux', 'order_by': 'bad order_by'}
request, response = test_client.post('/api/archive/search', content=json.dumps(data))
request, response = await async_client.post('/api/archive/search', content=json.dumps(data))
assert response.status_code == HTTPStatus.BAD_REQUEST
@ -106,7 +106,8 @@ async def test_search_archive_tags(test_session, async_client, archive_factory,
assert response.json['totals']['file_groups'] == 0
def test_archives_search_headline(test_session, archive_directory, archive_factory, test_client):
@pytest.mark.asyncio
async def test_archives_search_headline(test_session, archive_directory, archive_factory, async_client):
"""Headlines can be requested."""
archive_factory('example.com', 'https://example.com/one', 'my archive', 'foo bar qux')
archive_factory('example.com', 'https://example.com/one', 'other archive', 'foo baz qux qux')
@ -115,7 +116,7 @@ def test_archives_search_headline(test_session, archive_directory, archive_facto
test_session.commit()
content = dict(search_str='foo')
request, response = test_client.post('/api/archive/search', content=json.dumps(content))
request, response = await async_client.post('/api/archive/search', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
# Headlines are only fetched if requested.
@ -123,7 +124,7 @@ def test_archives_search_headline(test_session, archive_directory, archive_facto
assert response.json['file_groups'][1]['d_headline'] is None
content = dict(search_str='foo', headline=True)
request, response = test_client.post('/api/archive/search', content=json.dumps(content))
request, response = await async_client.post('/api/archive/search', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
# Postgresql uses <b>...</b> to highlight matching words.
@ -131,25 +132,27 @@ def test_archives_search_headline(test_session, archive_directory, archive_facto
assert response.json['file_groups'][1]['d_headline'] == '<b>foo</b> bar qux'
def test_search_offset(test_session, archive_factory, test_client):
@pytest.mark.asyncio
async def test_search_offset(test_session, archive_factory, async_client):
"""Archive search can be offset."""
for i in range(500):
archive_factory('example.com', f'https://example.com/{i}', contents='foo bar')
test_session.commit()
data = {'search_str': None, 'offset': 0}
check_results(test_client, data, list(range(500, 480, -1)))
await check_results(async_client, data, list(range(500, 480, -1)))
data = {'search_str': None, 'offset': 20}
check_results(test_client, data, list(range(480, 460, -1)))
await check_results(async_client, data, list(range(480, 460, -1)))
data = {'search_str': None, 'offset': 100}
check_results(test_client, data, list(range(400, 380, -1)))
await check_results(async_client, data, list(range(400, 380, -1)))
data = {'search_str': None, 'offset': 200}
check_results(test_client, data, list(range(300, 280, -1)))
await check_results(async_client, data, list(range(300, 280, -1)))
data = {'search_str': None, 'offset': 500}
check_results(test_client, data, [])
await check_results(async_client, data, [])
def test_archives_search_no_query(test_session, archive_factory, test_client):
@pytest.mark.asyncio
async def test_archives_search_no_query(test_session, archive_factory, async_client):
"""Archive Search API endpoint does not require data in the body."""
# Add 100 random archives.
for _ in range(100):
@ -157,83 +160,26 @@ def test_archives_search_no_query(test_session, archive_factory, test_client):
test_session.commit()
# All archives are returned when no `search_str` is passed.
request, response = test_client.post('/api/archive/search', content='{}')
request, response = await async_client.post('/api/archive/search', content='{}')
assert response.status_code == HTTPStatus.OK, response.json
assert [i['id'] for i in response.json['file_groups']] == list(range(100, 80, -1))
assert response.json['totals']['file_groups'] == 100
# All archives are from "example.com".
data = dict(domain='example.com')
request, response = test_client.post('/api/archive/search', content=json.dumps(data))
request, response = await async_client.post('/api/archive/search', content=json.dumps(data))
assert response.status_code == HTTPStatus.OK, response.json
assert [i['id'] for i in response.json['file_groups']] == list(range(100, 80, -1))
assert response.json['totals']['file_groups'] == 100
# No archives are from "example.org".
data = dict(domain='example.org')
request, response = test_client.post('/api/archive/search', content=json.dumps(data))
request, response = await async_client.post('/api/archive/search', content=json.dumps(data))
assert response.status_code == HTTPStatus.OK, response.json
assert [i['id'] for i in response.json['file_groups']] == []
assert response.json['totals']['file_groups'] == 0
def test_archive_and_domain_crud(test_session, test_client, archive_factory):
"""Getting an Archive returns it's File. Testing deleting Archives."""
# Can get empty results.
request, response = test_client.get(f'/api/archive/1')
assert response.status_code == HTTPStatus.NOT_FOUND
request, response = test_client.get(f'/api/archive/domains')
assert response.status_code == HTTPStatus.OK
assert response.json['domains'] == []
archive1 = archive_factory(domain='example.com', url='https://example.com/1')
archive2 = archive_factory(domain='example.com', url='https://example.com/1')
test_session.commit()
# Archive1 has Archive2 as history.
request, response = test_client.get(f'/api/archive/{archive1.id}')
assert response.status_code == HTTPStatus.OK
assert response.json['file_group']['id'] == archive1.id
assert response.json['history'][0]['id'] == archive2.id
# Archive2 has Archive1 as history.
request, response = test_client.get(f'/api/archive/{archive2.id}')
assert response.status_code == HTTPStatus.OK
assert archive2.id == response.json['file_group']['id']
assert response.json['history'][0]['id'] == archive1.id
# Only one domain.
request, response = test_client.get(f'/api/archive/domains')
assert response.status_code == HTTPStatus.OK
assert response.json['domains'][0]['domain'] == 'example.com'
assert response.json['domains'][0]['size'] == 254
# Deleting works.
request, response = test_client.delete(f'/api/archive/{archive1.id}')
assert response.status_code == HTTPStatus.NO_CONTENT
# Trying to delete again returns NOT_FOUND.
request, response = test_client.delete(f'/api/archive/{archive1.id}')
assert response.status_code == HTTPStatus.NOT_FOUND
# Can't get deleted Archive.
request, response = test_client.get(f'/api/archive/{archive1.id}')
assert response.status_code == HTTPStatus.NOT_FOUND
# Archive2 no longer has history.
request, response = test_client.get(f'/api/archive/{archive2.id}')
assert response.status_code == HTTPStatus.OK
assert response.json['file_group']['id'] == archive2.id
assert response.json['history'] == []
# No Archives, no Domains.
request, response = test_client.delete(f'/api/archive/{archive2.id}')
assert response.status_code == HTTPStatus.NO_CONTENT
request, response = test_client.get(f'/api/archive/domains')
assert response.status_code == HTTPStatus.OK
assert response.json['domains'] == []
@skip_macos
@skip_circleci
@pytest.mark.asyncio
@ -356,7 +302,8 @@ async def test_archive_upload_file_tracking(test_session, async_client, archive_
# Assert screenshot properties exist
assert archive.screenshot_path is not None, 'Archive.screenshot_path should exist'
assert archive.screenshot_file is not None, 'Archive.screenshot_file should exist'
assert archive.file_group.data.get('screenshot_path') == archive.screenshot_path, 'Archive screenshot should be in FileGroup.data'
assert archive.file_group.data.get(
'screenshot_path') == archive.screenshot_path, 'Archive screenshot should be in FileGroup.data'
# Assert image is in FileGroup.files
file_paths = [f['path'] for f in archive.file_group.files]

View file

@ -0,0 +1,139 @@
"""Test for collection_id bug when modeling archives from discovered files.
This test reproduces the bug where Archives created by model_archive()
don't have collection_id set, causing a NOT NULL constraint violation.
The bug happens because:
1. The archive_factory sets collection_id explicitly (masking the bug)
2. But model_archive() (used in production) doesn't set collection_id
3. This causes NULL constraint violations when indexing real files
"""
import pathlib
import pytest
from modules.archive import model_archive
from modules.archive.lib import archive_strftime
from wrolpi.collections import Collection
@pytest.mark.asyncio
async def test_model_archive_sets_collection_id(async_client, test_session, test_directory, make_files_structure):
"""
Test that model_archive() creates Archives with collection_id set.
This test uses model_archive() (the production code path) instead of
archive_factory, so it will detect the collection_id bug.
"""
import datetime
import pytz
# Create archive files with a URL in the singlefile
domain = 'example.com'
url = f'https://{domain}/test-page'
archive_dir = test_directory / 'archives' / domain
archive_dir.mkdir(parents=True)
timestamp = archive_strftime(datetime.datetime(2000, 1, 1, 0, 0, 0).astimezone(pytz.UTC))
title = 'Test Page'
# Create a minimal singlefile with URL embedded (note trailing space after SingleFile)
singlefile_content = f'''<!DOCTYPE html> <html lang="en"><!--
Page saved with SingleFile
url: {url}
saved date: Mon May 16 2022 23:51:35 GMT+0000 (Coordinated Universal Time)
--><head><meta charset="utf-8">
<meta name="generator" content="SingleFile">
<title>{title}</title>
</head>
<body>
<h1>{title}</h1>
<p>Test content</p>
</body>
</html>'''
files = make_files_structure({
str(archive_dir / f'{timestamp}_{title}.html'): singlefile_content.strip(),
str(archive_dir / f'{timestamp}_{title}.readability.json'): '{"title": "' + title + '"}',
})
from wrolpi.files.models import FileGroup
# Create FileGroup from the files (simulating what refresh does)
file_paths = [pathlib.Path(f) for f in files]
file_group = FileGroup.from_paths(test_session, *file_paths)
# Model the archive using the production code path
# This should create an Archive with collection_id set
archive = model_archive(file_group, session=test_session)
# The bug: collection_id is None, causing NOT NULL constraint violation
assert archive is not None, "Archive should be created"
assert archive.collection_id is not None, \
"Archive.collection_id should be set (this is the bug!)"
# Verify the collection was created
collection = test_session.query(Collection).filter_by(
name=domain,
kind='domain'
).one_or_none()
assert collection is not None, "Domain collection should be created"
assert archive.collection_id == collection.id, \
"Archive should be linked to the domain collection"
@pytest.mark.asyncio
async def test_model_archive_extracts_url_from_singlefile(async_client, test_session, test_directory, make_files_structure):
"""
Test that model_archive() can extract URL from singlefile when file_group.url is None.
This is the second part of the fix - if the URL isn't in the file_group yet,
we need to extract it from the singlefile content.
"""
import datetime
import pytz
# Create archive files
domain = 'test.org'
url = f'https://{domain}/article'
archive_dir = test_directory / 'archives' / domain
archive_dir.mkdir(parents=True)
timestamp = archive_strftime(datetime.datetime(2000, 1, 1, 0, 0, 0).astimezone(pytz.UTC))
# Create singlefile with URL in saved-from comment
singlefile_content = f'''<!DOCTYPE html> <html lang="en"><!--
Page saved with SingleFile
url: {url}
saved date: Mon May 16 2022 23:51:35 GMT+0000 (Coordinated Universal Time)
--><head><meta charset="utf-8">
<meta name="generator" content="SingleFile">
<title>Test Article</title>
</head>
<body><p>Content</p></body>
</html>'''
files = make_files_structure({
str(archive_dir / f'{timestamp}_Test.html'): singlefile_content.strip(),
})
from wrolpi.files.models import FileGroup
# Create FileGroup from the files (simulating what refresh does)
file_paths = [pathlib.Path(f) for f in files]
file_group = FileGroup.from_paths(test_session, *file_paths)
# Model the archive - it should extract the URL from the singlefile
archive = model_archive(file_group, session=test_session)
assert archive is not None
assert archive.file_group.url == url, \
"URL should be extracted from singlefile content"
assert archive.collection_id is not None, \
"collection_id should be set after URL extraction"
# Verify collection was created with correct domain
collection = test_session.query(Collection).get(archive.collection_id)
assert collection.name == domain
assert collection.kind == 'domain'

View file

@ -0,0 +1,313 @@
"""Tests for domain collection tagging functionality."""
import pytest
from wrolpi.collections import Collection
@pytest.mark.asyncio
async def test_create_domain_collection_with_directory(test_session, test_directory):
"""Domain collection can be created with a directory."""
domain_dir = test_directory / 'archives' / 'example.com'
domain_dir.mkdir(parents=True)
collection = Collection(
name='example.com',
kind='domain',
directory=domain_dir
)
test_session.add(collection)
test_session.commit()
assert collection.directory == domain_dir
assert collection.can_be_tagged is True
@pytest.mark.asyncio
async def test_create_unrestricted_domain_collection(test_session):
"""Unrestricted domain collection (no directory) cannot be tagged."""
collection = Collection(
name='example.com',
kind='domain',
directory=None # Unrestricted
)
test_session.add(collection)
test_session.commit()
assert collection.directory is None
assert collection.can_be_tagged is False
@pytest.mark.asyncio
async def test_tag_domain_collection_with_directory(async_client, test_session, test_directory, tag_factory):
"""Domain collection with directory can be tagged."""
domain_dir = test_directory / 'archives' / 'example.com'
domain_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='example.com',
kind='domain',
directory=domain_dir
)
test_session.add(collection)
tag = await tag_factory(name='News')
test_session.commit()
# Tag the collection
collection.set_tag('News')
test_session.commit()
assert collection.tag is not None
assert collection.tag.name == 'News'
assert collection.tag_id == tag.id
@pytest.mark.asyncio
async def test_cannot_tag_unrestricted_domain_collection(async_client, test_session, tag_factory):
"""Domain collection without directory cannot be tagged."""
collection = Collection(
name='example.com',
kind='domain',
directory=None # Unrestricted
)
test_session.add(collection)
tag = await tag_factory(name='News')
test_session.commit()
assert collection.can_be_tagged is False
with pytest.raises(ValueError, match='Cannot tag domain collection.*without a directory'):
collection.set_tag('News')
@pytest.mark.asyncio
async def test_tag_domain_collection_moves_files(
test_session, archive_factory, tag_factory, test_directory, make_files_structure, archive_directory,
await_switches,
):
"""Tagging domain collection with directory moves archive files."""
# Create domain collection with directory
# Use archive_directory fixture to match where archive_factory creates files
domain_dir = archive_directory / 'test.com'
domain_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='test.com',
kind='domain',
directory=domain_dir
)
test_session.add(collection)
test_session.flush()
# Create archives in domain directory
archive1 = archive_factory(domain='test.com')
archive2 = archive_factory(domain='test.com')
archive1.collection = collection
archive2.collection = collection
test_session.commit()
# Get file paths before move
old_paths = []
for archive in [archive1, archive2]:
for path in archive.my_paths():
old_paths.append(path)
assert all(p.is_file() for p in old_paths), "Files should exist before move"
# Create tag and assign to collection
tag = await tag_factory(name='Tech')
collection.set_tag('Tech')
test_session.commit()
# Compute new directory and move collection
new_directory = collection.format_directory('Tech')
# Create the destination directory before moving
new_directory.mkdir(parents=True, exist_ok=True)
await collection.move_collection(new_directory, test_session)
# Verify files moved
assert new_directory.is_dir(), "New directory should exist"
assert 'Tech' in str(new_directory), "New directory should contain tag name"
assert 'test.com' in str(new_directory), "New directory should contain domain name"
# Old paths should no longer exist
for old_path in old_paths:
assert not old_path.is_file(), f"Old file should be moved: {old_path}"
@pytest.mark.asyncio
async def test_domain_config_with_directory_and_tag(test_session, test_directory, tag_factory, async_client):
"""Domain config can include directory and tag_name."""
from modules.archive.lib import DomainsConfig
# Create config file
config_file = test_directory / 'domains.yaml'
config_file.write_text("""
collections:
- name: "example.com"
kind: "domain"
description: "News from example.com"
directory: "archives/example.com"
tag_name: "News"
""")
# Create tag first
tag = await tag_factory(name='News')
test_session.commit()
# Import config
domains_config = DomainsConfig()
domains_config.import_config(config_file)
# Verify collection created with directory and tag
collection = test_session.query(Collection).filter_by(
name='example.com',
kind='domain'
).one()
assert collection.directory is not None
assert 'example.com' in str(collection.directory)
assert collection.tag is not None
assert collection.tag.name == 'News'
assert collection.can_be_tagged is True
@pytest.mark.asyncio
async def test_domain_config_warns_tag_without_directory(test_session, test_directory, tag_factory, caplog,
async_client):
"""Config warns when tag_name provided without directory."""
from modules.archive.lib import DomainsConfig
# Create config file with tag but no directory
config_file = test_directory / 'domains.yaml'
config_file.write_text("""
collections:
- name: "example.com"
kind: "domain"
tag_name: "News"
""")
# Create tag first
tag = await tag_factory(name='News')
test_session.commit()
# Import config
domains_config = DomainsConfig()
domains_config.import_config(config_file)
# Verify collection created without tag
collection = test_session.query(Collection).filter_by(
name='example.com',
kind='domain'
).one()
assert collection.directory is None
assert collection.tag is None # Tag ignored
assert collection.can_be_tagged is False
# Check warning was logged
assert "tags require a directory" in caplog.text
@pytest.mark.asyncio
async def test_domain_config_export_includes_directory_and_tag(test_session, test_directory, tag_factory, async_client):
"""Exporting domain config includes directory and tag_name."""
from modules.archive.lib import DomainsConfig
from wrolpi.common import get_media_directory
import yaml
# Create domain collection with directory and tag
domain_dir = get_media_directory() / 'archives' / 'news.com'
domain_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='news.com',
kind='domain',
directory=domain_dir,
description='News website'
)
test_session.add(collection)
tag = await tag_factory(name='News')
collection.set_tag('News')
test_session.commit()
# Export config
config_file = test_directory / 'domains.yaml'
domains_config = DomainsConfig()
domains_config.dump_config(config_file, overwrite=True)
# Read and verify exported config
with open(config_file) as f:
exported = yaml.safe_load(f)
assert len(exported['collections']) == 1
domain_config = exported['collections'][0]
assert domain_config['name'] == 'news.com'
assert domain_config['kind'] == 'domain'
assert 'archives/news.com' in domain_config['directory']
assert domain_config['tag_name'] == 'News'
assert domain_config['description'] == 'News website'
@pytest.mark.asyncio
async def test_get_or_create_domain_collection_with_directory(test_session, test_directory):
"""get_or_create_domain_collection can create collection with directory."""
from modules.archive.lib import get_or_create_domain_collection
from wrolpi.common import get_media_directory
domain_dir = get_media_directory() / 'archives' / 'test.com'
domain_dir.mkdir(parents=True, exist_ok=True)
# Create with directory
collection = get_or_create_domain_collection(
test_session,
'https://test.com/article',
directory=domain_dir
)
assert collection.name == 'test.com'
assert collection.kind == 'domain'
assert collection.directory == domain_dir
assert collection.can_be_tagged is True
@pytest.mark.asyncio
async def test_get_archive_destination_with_directory(test_session, test_directory):
"""get_archive_destination returns collection directory when set."""
from modules.archive.lib import get_archive_destination
from wrolpi.common import get_media_directory
domain_dir = get_media_directory() / 'archives' / 'tagged' / 'example.com'
domain_dir.mkdir(parents=True, exist_ok=True)
collection = Collection(
name='example.com',
kind='domain',
directory=domain_dir
)
test_session.add(collection)
test_session.commit()
destination = get_archive_destination(collection)
assert destination == domain_dir
assert destination.is_dir()
@pytest.mark.asyncio
async def test_get_archive_destination_unrestricted(test_session):
"""get_archive_destination returns default path for unrestricted collection."""
from modules.archive.lib import get_archive_destination, get_archive_directory
collection = Collection(
name='example.com',
kind='domain',
directory=None # Unrestricted
)
test_session.add(collection)
test_session.commit()
destination = get_archive_destination(collection)
expected = get_archive_directory() / 'example.com'
assert destination == expected

View file

@ -10,10 +10,11 @@ from PIL import Image
from pytz import utc
from modules.archive import lib
from modules.archive.lib import get_or_create_domain, get_new_archive_files, delete_archives, model_archive_result, \
get_domains
from modules.archive.models import Archive, Domain
from modules.archive.lib import get_or_create_domain_collection, get_new_archive_files, delete_archives, \
model_archive_result, get_domains
from modules.archive.models import Archive
from wrolpi.api_utils import CustomJSONEncoder
from wrolpi.collections import Collection
from wrolpi.common import get_wrolpi_config
from wrolpi.db import get_db_session
from wrolpi.files import lib as files_lib
@ -37,7 +38,7 @@ def make_fake_archive_result(readability=True, screenshot=True, title=True):
@pytest.mark.asyncio
async def test_no_screenshot(test_directory, test_session):
async def test_no_screenshot(async_client, test_directory, test_session):
singlefile, readability, screenshot = make_fake_archive_result(screenshot=False)
archive = await model_archive_result('https://example.com', singlefile, readability, screenshot)
assert isinstance(archive.singlefile_path, pathlib.Path)
@ -47,7 +48,7 @@ async def test_no_screenshot(test_directory, test_session):
@pytest.mark.asyncio
async def test_no_readability(test_directory, test_session):
async def test_no_readability(async_client, test_directory, test_session):
singlefile, readability, screenshot = make_fake_archive_result(readability=False)
archive = await model_archive_result('https://example.com', singlefile, readability, screenshot)
assert isinstance(archive.singlefile_path, pathlib.Path)
@ -58,7 +59,7 @@ async def test_no_readability(test_directory, test_session):
@pytest.mark.asyncio
async def test_dict(test_session):
async def test_dict(async_client, test_session, test_directory):
singlefile, readability, screenshot = make_fake_archive_result()
d = (await model_archive_result('https://example.com', singlefile, readability, screenshot)).dict()
assert isinstance(d, dict)
@ -66,17 +67,18 @@ async def test_dict(test_session):
@pytest.mark.asyncio
async def test_relationships(test_session, example_singlefile):
async def test_relationships(async_client, test_session, example_singlefile):
with get_db_session(commit=True) as session:
url = 'https://wrolpi.org:443'
domain = get_or_create_domain(session, url)
collection = get_or_create_domain_collection(session, url)
archive = Archive.from_paths(test_session, example_singlefile)
archive.url = url
archive.domain_id = domain.id
archive.collection_id = collection.id
session.add(archive)
session.flush()
assert archive.domain == domain
assert archive.collection == collection
assert archive.domain == 'wrolpi.org'
@pytest.mark.asyncio
@ -119,8 +121,9 @@ async def test_archive_title(async_client, test_session, archive_factory, single
assert archive1.file_group.title is None
def test_archive_refresh_deleted_archive(test_client, test_session, archive_directory, archive_factory):
"""Archives/Domains should be deleted when archive files are deleted."""
@pytest.mark.asyncio
async def test_archive_refresh_deleted_archive(async_client, test_session, archive_directory, archive_factory):
"""Archives/domain collections should be deleted when archive files are deleted."""
archive1 = archive_factory('example.com', 'https://example.com/1')
archive2 = archive_factory('example.com', 'https://example.com/1')
archive3 = archive_factory('example.com')
@ -133,29 +136,30 @@ def test_archive_refresh_deleted_archive(test_client, test_session, archive_dire
def check_counts(archive_count, domain_count):
assert test_session.query(Archive).count() == archive_count, 'Archive count does not match'
assert test_session.query(Domain).count() == domain_count, 'Domain count does not match'
assert test_session.query(Collection).filter_by(
kind='domain').count() == domain_count, 'domain collection count does not match'
# All 5 archives are already in the DB.
check_counts(archive_count=5, domain_count=2)
test_client.post('/api/files/refresh')
await async_client.post('/api/files/refresh')
check_counts(archive_count=5, domain_count=1)
# Delete archive2's files, it's the latest for 'https://example.com/1'
for path in archive2.my_paths():
path.unlink()
test_client.post('/api/files/refresh')
await async_client.post('/api/files/refresh')
check_counts(archive_count=4, domain_count=1)
# Delete archive1's files, now the URL is empty.
for path in archive1.my_paths():
path.unlink()
test_client.post('/api/files/refresh')
await async_client.post('/api/files/refresh')
check_counts(archive_count=3, domain_count=0)
# Delete archive3, now there is now example.com domain
for path in archive3.my_paths():
path.unlink()
test_client.post('/api/files/refresh')
await async_client.post('/api/files/refresh')
check_counts(archive_count=2, domain_count=0)
# Delete all the rest of the archives
@ -163,12 +167,12 @@ def test_archive_refresh_deleted_archive(test_client, test_session, archive_dire
path.unlink()
for path in archive5.my_paths():
path.unlink()
test_client.post('/api/files/refresh')
await async_client.post('/api/files/refresh')
check_counts(archive_count=0, domain_count=0)
@pytest.mark.asyncio
async def test_fills_contents_with_refresh(test_session, archive_factory, singlefile_contents_factory):
async def test_fills_contents_with_refresh(async_client, test_session, archive_factory, singlefile_contents_factory):
"""Refreshing archives fills in any missing contents."""
archive1 = archive_factory('example.com', 'https://example.com/one')
archive2 = archive_factory('example.com', 'https://example.com/one')
@ -212,7 +216,8 @@ async def test_fills_contents_with_refresh(test_session, archive_factory, single
assert archive3.file_group.title == 'last title' # from singlefile HTML
def test_delete_archive(test_session, archive_factory):
@pytest.mark.asyncio
async def test_delete_archive(async_client, test_session, archive_factory):
"""Archives can be deleted."""
archive1 = archive_factory('example.com', 'https://example.com/1')
archive2 = archive_factory('example.com', 'https://example.com/1')
@ -227,30 +232,35 @@ def test_delete_archive(test_session, archive_factory):
assert test_session.query(Archive).count() == 3
assert test_session.query(FileGroup).count() == 3
# Save paths before deletion (archives become detached after delete)
archive1_paths = list(archive1.my_paths())
archive2_paths = list(archive2.my_paths())
archive3_paths = list(archive3.my_paths())
# Delete the oldest.
delete_archives(archive1.id, archive3.id)
assert test_session.query(Archive).count() == 1
assert test_session.query(FileGroup).count() == 1
# Files were deleted.
assert archive1.my_paths() and not any(i.is_file() for i in archive1.my_paths())
assert archive3.my_paths() and not any(i.is_file() for i in archive3.my_paths())
assert archive1_paths and not any(i.is_file() for i in archive1_paths)
assert archive3_paths and not any(i.is_file() for i in archive3_paths)
# Archive2 is untouched
assert archive2.my_paths() and all(i.is_file() for i in archive2.my_paths())
assert archive2_paths and all(i.is_file() for i in archive2_paths)
# Delete the last archive. The Domain should also be deleted.
# Delete the last archive. The domain collection should also be deleted.
delete_archives(archive2.id)
assert test_session.query(Archive).count() == 0
domain = test_session.query(Domain).one_or_none()
domain = test_session.query(Collection).filter_by(kind='domain').one_or_none()
assert domain is None
# All Files were deleted.
assert test_session.query(FileGroup).count() == 0
assert archive2.my_paths() and not any(i.is_file() for i in archive2.my_paths())
assert archive2_paths and not any(i.is_file() for i in archive2_paths)
def test_get_domains(test_session, archive_factory):
"""
`get_domains` gets only Domains with Archives.
`get_domains` gets only domain collections with Archives.
"""
archive1 = archive_factory('example.com')
archive2 = archive_factory('example.com')
@ -316,7 +326,7 @@ async def test_new_archive(test_session, test_directory, fake_now):
fake_now(datetime(2000, 1, 2))
archive2 = await model_archive_result('https://example.com', singlefile, readability, screenshot)
# Domain is reused.
# domain collection is reused.
assert archive1.domain == archive2.domain
@ -583,7 +593,7 @@ async def test_archive_meta(async_client, test_session, make_files_structure):
@pytest.mark.asyncio
async def test_refresh_archives_deleted_singlefile(test_session, make_files_structure, singlefile_contents_factory):
async def test_refresh_archives_deleted_singlefile(async_client, test_session, make_files_structure, singlefile_contents_factory):
"""Removing a Singlefile file from a FileGroup makes that group no longer an Archive."""
singlefile, readability = make_files_structure({
'2022-09-04-16-20-11_The Title.html': singlefile_contents_factory(),
@ -851,3 +861,218 @@ async def test_get_custom_archive_directory(async_client, test_directory, test_w
assert lib.get_archive_directory() == (test_directory / 'custom/archives')
assert lib.get_domain_directory('https://example.com') == (test_directory / 'custom/archives/example.com')
@pytest.mark.asyncio
async def test_detect_domain_directory_single_archive(async_client, test_directory, test_session, archive_factory):
"""detect_domain_directory should detect directory when all archives are in same location."""
from modules.archive.lib import detect_domain_directory
# Create an archive using the factory (which creates a domain collection and auto-detects directory)
archive = archive_factory(domain='example.com', url='https://example.com/page1')
test_session.flush()
# Get the collection
collection = archive.collection
assert collection is not None
assert collection.name == 'example.com'
# Directory should have been auto-detected during collection creation
assert collection.directory is not None
assert 'example.com' in str(collection.directory)
# Verify detect_domain_directory also returns the same result
detected = detect_domain_directory(collection, test_session)
assert detected is not None
assert str(detected) == 'archive/example.com'
@pytest.mark.asyncio
async def test_detect_domain_directory_multiple_archives(async_client, test_directory, test_session, archive_factory):
"""detect_domain_directory should detect common directory for multiple archives."""
from modules.archive.lib import detect_domain_directory
# Create multiple archives in the same domain
archive1 = archive_factory(domain='test.com', url='https://test.com/page1')
archive2 = archive_factory(domain='test.com', url='https://test.com/page2')
test_session.flush()
# Get the collection
collection = archive1.collection
assert collection is not None
assert collection.name == 'test.com'
# Detect directory
detected = detect_domain_directory(collection, test_session)
assert detected is not None
assert str(detected) == 'archive/test.com'
def test_detect_domain_directory_no_archives(test_directory, test_session):
"""detect_domain_directory should return None when collection has no archives."""
from modules.archive.lib import detect_domain_directory
# Create a domain collection without archives
collection = Collection(name='empty.com', kind='domain', directory=None)
test_session.add(collection)
test_session.flush()
# Detect directory - should return None
detected = detect_domain_directory(collection, test_session)
assert detected is None
@pytest.mark.asyncio
async def test_get_or_create_domain_collection_auto_detects_directory(async_client, test_directory, test_session, archive_factory):
"""get_or_create_domain_collection should auto-detect directory for existing archives."""
from modules.archive.lib import get_or_create_domain_collection
# Create an archive first
archive = archive_factory(domain='autodetect.com', url='https://autodetect.com/page1')
test_session.commit()
# The collection should have been created with no directory initially
# Call get_or_create_domain_collection again - should auto-detect
collection = get_or_create_domain_collection(test_session, 'https://autodetect.com/page2')
assert collection.directory is not None
assert 'autodetect.com' in str(collection.directory)
@pytest.mark.asyncio
async def test_update_domain_directories(async_client, test_directory, test_session, archive_factory):
"""update_domain_directories should fix existing collections that lost their directory."""
from modules.archive.lib import update_domain_directories
# Create an archive - this creates a collection WITH directory (auto-detected)
archive = archive_factory(domain='needsdir.com', url='https://needsdir.com/page1')
test_session.commit()
# Manually clear the directory to simulate legacy data
collection = archive.collection
collection.directory = None
test_session.commit()
# Verify collection has no directory
assert collection.directory is None
# Run update
count = update_domain_directories(test_session)
assert count == 1
# Verify directory was set
test_session.expire(collection)
assert collection.directory is not None
assert 'needsdir.com' in str(collection.directory)
def test_collection_unique_name_kind_constraint(test_directory, test_session, archive_factory):
"""Collections should have unique (name, kind) combinations.
This prevents duplicate domain collections with the same name.
"""
from sqlalchemy.exc import IntegrityError
# Create an archive which creates a domain collection
archive = archive_factory(domain='uniquetest.com')
test_session.commit()
# Get the original collection
original_collection = archive.collection
assert original_collection is not None
# Attempting to create a duplicate domain collection should fail
duplicate_collection = Collection(name='uniquetest.com', kind='domain', directory=None)
test_session.add(duplicate_collection)
with pytest.raises(IntegrityError) as exc_info:
test_session.commit()
# Verify it's the unique constraint violation
assert 'uq_collection_name_kind' in str(exc_info.value)
test_session.rollback()
def test_search_archives_by_domain(test_directory, test_session, archive_factory):
"""search_archives should correctly filter by domain collection name."""
from modules.archive.lib import search_archives
# Create archives in different domains
archive1 = archive_factory(domain='searchtest.com')
archive2 = archive_factory(domain='searchtest.com')
archive3 = archive_factory(domain='other.com')
test_session.commit()
# Search for archives in searchtest.com
file_groups, total = search_archives(
search_str=None,
domain='searchtest.com',
limit=10,
offset=0,
order=None,
tag_names=None
)
# Should return only archives from searchtest.com
assert total == 2
assert len(file_groups) == 2
# Search for other.com
file_groups, total = search_archives(
search_str=None,
domain='other.com',
limit=10,
offset=0,
order=None,
tag_names=None
)
assert total == 1
assert len(file_groups) == 1
def test_link_domain_and_downloads(test_session, test_download_manager):
"""Test that downloads are linked to domain collections."""
from wrolpi.downloader import Download
from modules.archive.lib import link_domain_and_downloads
# Create a domain collection with a directory
collection = Collection(name='example.com', kind='domain', directory='archive/example.com')
test_session.add(collection)
test_session.commit()
# 1. Download with matching destination directory (exact match)
download1 = Download(
url='https://other.com/rss',
downloader='rss',
sub_downloader='archive',
frequency=86400,
settings={'destination': 'archive/example.com'}
)
# 2. Download with destination in subdirectory
download2 = Download(
url='https://other.com/rss2',
downloader='rss',
sub_downloader='archive',
frequency=86400,
settings={'destination': 'archive/example.com/2025/01'}
)
# 3. RSS download with archive sub_downloader (matches by URL domain)
download3 = Download(
url='https://example.com/feed.xml',
downloader='rss',
sub_downloader='archive',
frequency=86400
)
# 4. Download without frequency (should NOT be linked)
download4 = Download(url='https://example.com/once', downloader='archive')
test_session.add_all([download1, download2, download3, download4])
test_session.commit()
assert not any(d.collection_id for d in [download1, download2, download3, download4])
link_domain_and_downloads(test_session)
assert download1.collection_id == collection.id # matched by directory (exact)
assert download2.collection_id == collection.id # matched by subdirectory
assert download3.collection_id == collection.id # matched by URL domain
assert download4.collection_id is None # no frequency = no link

View file

@ -1,9 +1,9 @@
from datetime import datetime
import pathlib
from datetime import datetime
import pytest
from modules.archive import Domain, Archive
from modules.archive import Archive
from wrolpi.common import get_wrolpi_config
@ -13,23 +13,23 @@ async def test_archive_download_destination(async_client, test_session, test_dir
wrolpi_config = get_wrolpi_config()
archive_factory(domain='wrolpi.org')
domain = test_session.query(Domain).one()
archive = archive_factory(domain='wrolpi.org')
test_session.commit()
# Test the default download directory.
assert str(domain.download_directory) == str(test_directory / 'archive/wrolpi.org')
assert str(archive.download_directory) == str(test_directory / 'archive/wrolpi.org')
# Year of download is supported.
wrolpi_config.archive_destination = 'archives/%(domain)s/%(year)s'
assert str(domain.download_directory) == str(test_directory / 'archives/wrolpi.org/2000')
assert str(archive.download_directory) == str(test_directory / 'archives/wrolpi.org/2000')
# More download date is supported.
wrolpi_config.archive_destination = 'archive/%(domain)s/%(year)s/%(month)s/%(day)s'
assert str(domain.download_directory) == str(test_directory / 'archive/wrolpi.org/2000/1/2')
assert str(archive.download_directory) == str(test_directory / 'archive/wrolpi.org/2000/1/2')
@pytest.mark.asyncio
async def test_set_screenshot(test_session, archive_factory, image_bytes_factory):
async def test_set_screenshot(async_client, test_session, archive_factory, image_bytes_factory):
"""Test Archive.set_screenshot() method with various scenarios."""
# ========== Test 1: Successfully set screenshot on archive without one ==========
@ -84,7 +84,8 @@ async def test_set_screenshot(test_session, archive_factory, image_bytes_factory
assert archive.screenshot_path == original_screenshot
# ========== Test 3: Error when trying to set non-existent screenshot file ==========
archive_no_screenshot = archive_factory('example.com', 'https://example.com/test2', 'Test Archive 2', screenshot=False)
archive_no_screenshot = archive_factory('example.com', 'https://example.com/test2', 'Test Archive 2',
screenshot=False)
test_session.commit()
# Try to set a screenshot that doesn't exist

View file

@ -7,7 +7,8 @@ from modules.inventory import Item
from modules.inventory.inventory import save_item
def test_delete_items(test_session, init_test_inventory, test_client):
@pytest.mark.asyncio
async def test_delete_items(test_session, init_test_inventory, async_client):
"""
Multiple Items can be deleted in a single request.
"""
@ -24,14 +25,15 @@ def test_delete_items(test_session, init_test_inventory, test_client):
save_item(init_test_inventory.id, item)
items = test_session.query(Item).filter(Item.brand != None).all()
request, response = test_client.delete(f'/api/inventory/item/{items[0].id},{items[1].id}')
request, response = await async_client.delete(f'/api/inventory/item/{items[0].id},{items[1].id}')
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
# Refresh all items. Verify they were deleted.
[test_session.refresh(i) for i in items]
assert all([i.deleted_at for i in items])
def test_delete_item(test_session, init_test_inventory, test_client):
@pytest.mark.asyncio
async def test_delete_item(test_session, init_test_inventory, async_client):
"""
An item can be deleted by itself.
"""
@ -48,22 +50,23 @@ def test_delete_item(test_session, init_test_inventory, test_client):
item = test_session.query(Item).filter(Item.brand != None).one()
assert not item.deleted_at
request, response = test_client.delete(f'/api/inventory/item/{item.id}')
request, response = await async_client.delete(f'/api/inventory/item/{item.id}')
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
test_session.refresh(item)
assert item.deleted_at
def test_item_api(test_session, init_test_inventory, test_client):
@pytest.mark.asyncio
async def test_item_api(test_session, init_test_inventory, async_client):
"""An Item can be added to an Inventory."""
inventory_id = init_test_inventory.id
item = {'brand': 'Salty', 'name': 'Salt', 'item_size': '1', 'unit': 'lbs', 'count': '5',
'category': 'cooking ingredients', 'subcategory': 'salt', 'expiration_date': None}
request, response = test_client.post(f'/api/inventory/{inventory_id}/item', content=json.dumps(item))
request, response = await async_client.post(f'/api/inventory/{inventory_id}/item', content=json.dumps(item))
assert response.status_code == HTTPStatus.NO_CONTENT, response.status_code
# Get the Item we just created.
request, response = test_client.get(f'/api/inventory/{inventory_id}/item')
request, response = await async_client.get(f'/api/inventory/{inventory_id}/item')
assert response.status_code == HTTPStatus.OK
# assert item == response.json['items'][0]
item = response.json['items'][0]
@ -71,7 +74,7 @@ def test_item_api(test_session, init_test_inventory, test_client):
expiration_dates = ('1969-12-31T20:25:45.123450', None)
for expiration_date in expiration_dates:
item['expiration_date'] = expiration_date
request, response = test_client.put(f'/api/inventory/item/{item["id"]}', content=json.dumps(item))
request, response = await async_client.put(f'/api/inventory/item/{item["id"]}', content=json.dumps(item))
assert response.status_code == HTTPStatus.NO_CONTENT, response.json

View file

@ -3,10 +3,11 @@ from http import HTTPStatus
from itertools import zip_longest
import mock
import pytest
def assert_map_file_status(test_client, expected, status_code=HTTPStatus.OK):
request, response = test_client.get('/api/map/files')
async def assert_map_file_status(async_client, expected, status_code=HTTPStatus.OK):
request, response = await async_client.get('/api/map/files')
assert response.status_code == status_code
for pbf, expected_ in zip_longest(response.json['files'], expected):
imported = 'to be imported' if expected_['imported'] else 'to NOT be imported'
@ -16,9 +17,10 @@ def assert_map_file_status(test_client, expected, status_code=HTTPStatus.OK):
assert response.json['pending'] is None
@pytest.mark.asyncio
@mock.patch('modules.map.lib.is_pbf_file', lambda i: True)
@mock.patch('modules.map.lib.is_dump_file', lambda i: True)
def test_status_and_import(test_client, test_session, make_files_structure, mock_run_command):
async def test_status_and_import(async_client, test_session, make_files_structure, mock_run_command):
"""PBF files can be imported, and the status of the import can be monitored."""
pbf1, pbf2, dump1 = make_files_structure([
'map/pbf/some-country.osm.pbf',
@ -32,10 +34,10 @@ def test_status_and_import(test_client, test_session, make_files_structure, mock
{'imported': False, 'path': 'map/pbf/other-country.osm.pbf'},
{'imported': False, 'path': 'map/pbf/some-country.osm.pbf'},
]
assert_map_file_status(test_client, expected)
await assert_map_file_status(async_client, expected)
body = {'files': [str(pbf1), str(pbf2)]}
request, response = test_client.post('/api/map/import', content=json.dumps(body))
request, response = await async_client.post('/api/map/import', content=json.dumps(body))
assert response.status_code == HTTPStatus.NO_CONTENT
# Can import multiple pbf files.
@ -44,10 +46,10 @@ def test_status_and_import(test_client, test_session, make_files_structure, mock
{'imported': True, 'path': 'map/pbf/other-country.osm.pbf'},
{'imported': True, 'path': 'map/pbf/some-country.osm.pbf'},
]
assert_map_file_status(test_client, expected)
await assert_map_file_status(async_client, expected)
body = {'files': [str(pbf1), ], }
request, response = test_client.post('/api/map/import', content=json.dumps(body))
request, response = await async_client.post('/api/map/import', content=json.dumps(body))
assert response.status_code == HTTPStatus.NO_CONTENT
# Importing one pbf resets the import statuses.
@ -56,10 +58,10 @@ def test_status_and_import(test_client, test_session, make_files_structure, mock
{'imported': False, 'path': 'map/pbf/other-country.osm.pbf'},
{'imported': True, 'path': 'map/pbf/some-country.osm.pbf'},
]
assert_map_file_status(test_client, expected)
await assert_map_file_status(async_client, expected)
body = {'files': [str(dump1), ]}
request, response = test_client.post('/api/map/import', content=json.dumps(body))
request, response = await async_client.post('/api/map/import', content=json.dumps(body))
assert response.status_code == HTTPStatus.NO_CONTENT
# Importing a dump also resets statuses.
@ -68,12 +70,13 @@ def test_status_and_import(test_client, test_session, make_files_structure, mock
{'imported': False, 'path': 'map/pbf/other-country.osm.pbf'},
{'imported': True, 'path': 'map/pbf/some-country.osm.pbf'},
]
assert_map_file_status(test_client, expected)
await assert_map_file_status(async_client, expected)
@pytest.mark.asyncio
@mock.patch('modules.map.lib.is_pbf_file', lambda i: True)
@mock.patch('modules.map.lib.is_dump_file', lambda i: True)
def test_multiple_import(test_client, test_session, test_directory, make_files_structure, mock_run_command):
async def test_multiple_import(async_client, test_session, test_directory, make_files_structure, mock_run_command):
"""Multiple PBFs can be imported. Importing a second time overwrites the previous imports."""
pbf1, pbf2, pbf3, dump = make_files_structure([
'map/pbf/country1.osm.pbf',
@ -83,7 +86,7 @@ def test_multiple_import(test_client, test_session, test_directory, make_files_s
])
body = {'files': [str(pbf2), str(pbf1)]}
request, response = test_client.post('/api/map/import', content=json.dumps(body))
request, response = await async_client.post('/api/map/import', content=json.dumps(body))
assert response.status_code == HTTPStatus.NO_CONTENT
# Two PBFs can be merged and imported.
@ -93,10 +96,10 @@ def test_multiple_import(test_client, test_session, test_directory, make_files_s
{'imported': True, 'path': 'map/pbf/country2.osm.pbf'},
{'imported': False, 'path': 'map/pbf/country3.osm.pbf'},
]
assert_map_file_status(test_client, expected)
await assert_map_file_status(async_client, expected)
body = {'files': [str(pbf3), ]}
request, response = test_client.post('/api/map/import', content=json.dumps(body))
request, response = await async_client.post('/api/map/import', content=json.dumps(body))
assert response.status_code == HTTPStatus.NO_CONTENT
# New imports overwrite old imports.
@ -106,11 +109,11 @@ def test_multiple_import(test_client, test_session, test_directory, make_files_s
{'imported': False, 'path': 'map/pbf/country2.osm.pbf'},
{'imported': True, 'path': 'map/pbf/country3.osm.pbf'},
]
assert_map_file_status(test_client, expected)
await assert_map_file_status(async_client, expected)
# Can't mix PBF and dump.
body = {'files': [str(dump), ]}
request, response = test_client.post('/api/map/import', content=json.dumps(body))
request, response = await async_client.post('/api/map/import', content=json.dumps(body))
assert response.status_code == HTTPStatus.NO_CONTENT
# Dump does not overwrite.
@ -120,12 +123,13 @@ def test_multiple_import(test_client, test_session, test_directory, make_files_s
{'imported': False, 'path': 'map/pbf/country2.osm.pbf'},
{'imported': True, 'path': 'map/pbf/country3.osm.pbf'},
]
assert_map_file_status(test_client, expected)
await assert_map_file_status(async_client, expected)
def test_empty_import(test_client, test_session, test_directory):
@pytest.mark.asyncio
async def test_empty_import(async_client, test_session, test_directory):
"""Some files must be requested."""
body = {'files': []}
request, response = test_client.post('/api/map/import', content=json.dumps(body))
request, response = await async_client.post('/api/map/import', content=json.dumps(body))
assert response.status_code == HTTPStatus.BAD_REQUEST
assert 'validate' in response.json['message']

View file

@ -1,10 +1,13 @@
import json
from http import HTTPStatus
import pytest
def test_encrypt(test_client):
@pytest.mark.asyncio
async def test_encrypt(async_client):
body = dict(otp='asdf', plaintext='asdf')
request, response = test_client.post('/api/otp/encrypt_otp', content=json.dumps(body))
request, response = await async_client.post('/api/otp/encrypt_otp', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK
assert response.json == dict(
ciphertext='AAGK',
@ -13,9 +16,10 @@ def test_encrypt(test_client):
)
def test_decrypt(test_client):
@pytest.mark.asyncio
async def test_decrypt(async_client):
body = dict(otp='asdf', ciphertext='aagk')
request, response = test_client.post('/api/otp/decrypt_otp', content=json.dumps(body))
request, response = await async_client.post('/api/otp/decrypt_otp', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK
assert response.json == dict(
ciphertext='AAGK',
@ -24,6 +28,7 @@ def test_decrypt(test_client):
)
def test_get_html(test_client):
request, response = test_client.get('/api/otp/html')
@pytest.mark.asyncio
async def test_get_html(async_client):
request, response = await async_client.get('/api/otp/html')
assert response.status_code == HTTPStatus.OK

View file

@ -75,18 +75,19 @@ def video_cleanup():
with get_db_curs(commit=True) as curs:
# Delete all Videos if the FileModel no longer contains a video.
curs.execute('''
WITH deleted AS
(UPDATE file_group SET model=null WHERE model='video' AND mimetype NOT LIKE 'video/%' RETURNING id)
DELETE FROM video WHERE file_group_id = ANY(select id from deleted)
''')
WITH deleted AS
(UPDATE file_group SET model = null WHERE model = 'video' AND mimetype NOT LIKE 'video/%' RETURNING id)
DELETE
FROM video
WHERE file_group_id = ANY (select id from deleted)
''')
# Claim all Videos in a Channel's directory for that Channel. But, only if they have not yet been claimed.
curs.execute('''
UPDATE video v
SET
channel_id = c.id
FROM channel c
LEFT JOIN file_group fg ON fg.primary_path LIKE c.directory || '/%'::VARCHAR
WHERE
v.channel_id IS NULL
AND fg.id = v.file_group_id
''')
UPDATE video v
SET channel_id = c.id
FROM channel c
INNER JOIN collection col ON col.id = c.collection_id
LEFT JOIN file_group fg ON fg.primary_path LIKE col.directory || '/%'::VARCHAR
WHERE v.channel_id IS NULL
AND fg.id = v.file_group_id
''')

View file

@ -1,16 +1,18 @@
import pathlib
from pathlib import Path
from typing import List, Dict, Union
from typing import List, Union
from sqlalchemy import or_, func, desc, asc
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from wrolpi import flags
from wrolpi.collections import Collection
from wrolpi.common import logger, \
get_media_directory, wrol_mode_check, background_task
from wrolpi.db import get_db_curs, optional_session, get_db_session
from wrolpi.downloader import save_downloads_config, download_manager, Download
from wrolpi.errors import APIError, ValidationError, RefreshConflict
from wrolpi.tags import Tag
from wrolpi.vars import PYTEST
from .. import schema
from ..common import check_for_channel_conflicts
@ -30,15 +32,16 @@ async def get_minimal_channels() -> List[dict]:
# one that will consume the most resources.
stmt = '''
SELECT c.id,
c.name AS "name",
c.directory,
col.name AS "name",
col.directory,
c.url,
t.name AS "tag_name",
t.name AS "tag_name",
c.video_count,
c.total_size,
c.minimum_frequency
FROM channel AS c
LEFT JOIN tag t ON t.id = c.tag_id
INNER JOIN collection col ON col.id = c.collection_id
LEFT JOIN tag t ON t.id = col.tag_id
'''
curs.execute(stmt)
logger.debug(stmt)
@ -60,19 +63,22 @@ def get_channel(session: Session, *, channel_id: int = None, source_id: str = No
"""
channel: COD = None # noqa
# Try to find the channel by the most reliable methods first.
# Always eagerly load Collection to ensure name/directory/tag_id are available
if channel_id:
channel = session.query(Channel).filter_by(id=channel_id).one_or_none()
channel = session.query(Channel).options(joinedload(Channel.collection)).filter_by(id=channel_id).one_or_none()
if not channel and source_id:
channel = session.query(Channel).filter_by(source_id=source_id).one_or_none()
channel = session.query(Channel).options(joinedload(Channel.collection)).filter_by(
source_id=source_id).one_or_none()
if not channel and url:
channel = session.query(Channel).filter_by(url=url).one_or_none()
channel = session.query(Channel).options(joinedload(Channel.collection)).filter_by(url=url).one_or_none()
if not channel and directory:
directory = Path(directory)
if not directory.is_absolute():
directory = get_media_directory() / directory
channel = session.query(Channel).filter_by(directory=directory).one_or_none()
channel = session.query(Channel).join(Collection).filter(
Collection.directory == str(directory)).one_or_none()
if not channel and name:
channel = session.query(Channel).filter_by(name=name).one_or_none()
channel = session.query(Channel).join(Collection).filter(Collection.name == name).one_or_none()
if not channel:
raise UnknownChannel(f'No channel matches {channel_id=} {source_id=} {url=} {directory=}')
@ -147,7 +153,17 @@ def create_channel(session: Session, data: schema.ChannelPostRequest, return_dic
directory = get_media_directory() / directory if not directory.is_absolute() else directory
directory.mkdir(parents=True, exist_ok=True)
channel = Channel()
# Create Collection first
collection = Collection(
name=data.name,
kind='channel',
directory=directory,
)
session.add(collection)
session.flush([collection])
# Create Channel linked to Collection
channel = Channel(collection_id=collection.id)
session.add(channel)
session.flush([channel])
# Apply the changes now that we've OK'd them
@ -179,22 +195,24 @@ async def search_channels_by_name(name: str, limit: int = 5, session: Session =
name_no_spaces = ''.join(name.split(' '))
if order_by_video_count:
stmt = session.query(Channel, func.count(Video.id).label('video_count')) \
.join(Collection) \
.filter(or_(
Channel.name.ilike(f'%{name}%'),
Channel.name.ilike(f'%{name_no_spaces}%'),
Collection.name.ilike(f'%{name}%'),
Collection.name.ilike(f'%{name_no_spaces}%'),
)) \
.outerjoin(Video, Video.channel_id == Channel.id) \
.group_by(Channel.id, Channel.name) \
.order_by(desc('video_count'), asc(Channel.name)) \
.group_by(Channel.id, Collection.id, Collection.name) \
.order_by(desc('video_count'), asc(Collection.name)) \
.limit(limit)
channels = [i[0] for i in stmt]
else:
stmt = session.query(Channel) \
.join(Collection) \
.filter(or_(
Channel.name.ilike(f'%{name}%'),
Channel.name.ilike(f'%{name_no_spaces}%'),
Collection.name.ilike(f'%{name}%'),
Collection.name.ilike(f'%{name_no_spaces}%'),
)) \
.order_by(asc(Channel.name)) \
.order_by(asc(Collection.name)) \
.limit(limit)
channels = stmt.all()
return channels
@ -270,6 +288,5 @@ async def tag_channel(tag_name: str | None, directory: pathlib.Path | None, chan
@optional_session
async def search_channels(tag_names: List[str], session: Session) -> List[Channel]:
"""Search Tagged Channels."""
from wrolpi.tags import Tag
channels = session.query(Channel).join(Tag).filter(Tag.name.in_(tag_names)).all()
channels = session.query(Channel).join(Collection).join(Tag).filter(Tag.name.in_(tag_names)).all()
return channels

View file

@ -21,16 +21,18 @@ from wrolpi.files.models import Directory
from wrolpi.test.common import assert_dict_contains
def test_get_channels(test_directory, channel_factory, test_client):
@pytest.mark.asyncio
async def test_get_channels(test_directory, channel_factory, async_client):
channel_factory()
channel_factory()
channel_factory()
request, response = test_client.get('/api/videos/channels')
request, response = await async_client.get('/api/videos/channels')
assert response.status_code == HTTPStatus.OK
assert len(response.json['channels']) == 3
def test_get_video(test_client, test_session, simple_channel, video_factory):
@pytest.mark.asyncio
async def test_get_video(async_client, test_session, simple_channel, video_factory):
"""Test that you get can information about a video. Test that video file can be gotten."""
now_ = now()
video1 = video_factory(channel_id=simple_channel.id, title='vid1')
@ -40,14 +42,14 @@ def test_get_video(test_client, test_session, simple_channel, video_factory):
test_session.commit()
# Test that a 404 is returned when no video exists
_, response = test_client.get('/api/videos/video/10')
_, response = await async_client.get('/api/videos/video/10')
assert response.status_code == HTTPStatus.NOT_FOUND, response.json
assert response.json == {'code': 'UNKNOWN_VIDEO',
'error': 'Cannot find Video with id 10',
'message': 'The video could not be found.'}
# Get the video info we inserted
_, response = test_client.get('/api/videos/video/1')
_, response = await async_client.get('/api/videos/video/1')
assert response.status_code == HTTPStatus.OK, response.json
assert_dict_contains(response.json['file_group'], {'title': 'vid1'})
@ -92,9 +94,10 @@ async def test_channel_no_download_frequency(async_client, test_session, test_di
assert test_session.query(Channel).count() == 1
def test_video_file_name(test_session, simple_video, test_client):
@pytest.mark.asyncio
async def test_video_file_name(test_session, simple_video, async_client):
"""If a Video has no title, the front-end can use the file name as the title."""
_, resp = test_client.get(f'/api/videos/video/{simple_video.id}')
_, resp = await async_client.get(f'/api/videos/video/{simple_video.id}')
assert resp.status_code == HTTPStatus.OK
assert resp.json['file_group']['video']['video_path'] == 'simple_video.mp4'
assert resp.json['file_group']['video'].get('stem') == 'simple_video'
@ -165,20 +168,21 @@ async def test_channel_conflicts(async_client, test_session, test_directory):
'message': 'Could not validate the contents of the request'}
def test_channel_empty_url_doesnt_conflict(test_client, test_session, test_directory):
@pytest.mark.asyncio
async def test_channel_empty_url_doesnt_conflict(async_client, test_session, test_directory):
"""Two channels with empty URLs shouldn't conflict"""
channel_directory = tempfile.TemporaryDirectory(dir=test_directory).name
pathlib.Path(channel_directory).mkdir()
new_channel = dict(name='Fooz', directory=channel_directory)
request, response = test_client.post('/api/videos/channels', json=new_channel)
request, response = await async_client.post('/api/videos/channels', json=new_channel)
assert response.status_code == HTTPStatus.CREATED, response.json
location = response.headers['Location']
channel_directory2 = tempfile.TemporaryDirectory(dir=test_directory).name
pathlib.Path(channel_directory2).mkdir()
new_channel = dict(name='Barz', directory=channel_directory2)
request, response = test_client.post('/api/videos/channels', json=new_channel)
request, response = await async_client.post('/api/videos/channels', json=new_channel)
assert response.status_code == HTTPStatus.CREATED, response.json
assert location != response.headers['Location']
@ -235,11 +239,12 @@ async def test_channel_download_requires_refresh(
assert list(events_history) == []
def test_channel_post_directory(test_session, test_client, test_directory):
@pytest.mark.asyncio
async def test_channel_post_directory(test_session, async_client, test_directory):
"""A Channel can be created with or without an existing directory."""
# Channel can be created with a directory which is not on disk.
data = dict(name='foo', directory='foo')
request, response = test_client.post('/api/videos/channels', content=json.dumps(data))
request, response = await async_client.post('/api/videos/channels', content=json.dumps(data))
assert response.status_code == HTTPStatus.CREATED
directory = test_session.query(Channel).filter_by(id=1).one().directory
assert (test_directory / 'foo') == directory
@ -247,8 +252,9 @@ def test_channel_post_directory(test_session, test_client, test_directory):
assert directory.is_absolute()
def test_channel_by_id(test_session, test_client, simple_channel, simple_video):
request, response = test_client.get(f'/api/videos/channels/{simple_channel.id}')
@pytest.mark.asyncio
async def test_channel_by_id(test_session, async_client, simple_channel, simple_video):
request, response = await async_client.get(f'/api/videos/channels/{simple_channel.id}')
assert response.status_code == HTTPStatus.OK
@ -334,10 +340,20 @@ async def test_change_channel_url(async_client, test_session, test_download_mana
assert channel.url == 'https://example.com/new-url', "Channel's URL was not changed."
def test_search_videos_channel(test_client, test_session, video_factory):
@pytest.mark.asyncio
async def test_search_videos_channel(async_client, test_session, video_factory, test_directory):
from wrolpi.collections import Collection
with get_db_session(commit=True) as session:
channel1 = Channel(name='Foo')
channel2 = Channel(name='Bar')
# Create Collections first
collection1 = Collection(name='Foo', kind='channel', directory=test_directory / 'foo')
collection2 = Collection(name='Bar', kind='channel', directory=test_directory / 'bar')
session.add_all([collection1, collection2])
session.flush()
# Create Channels linked to Collections
channel1 = Channel(collection_id=collection1.id)
channel2 = Channel(collection_id=collection2.id)
session.add(channel1)
session.add(channel2)
session.flush()
@ -346,7 +362,7 @@ def test_search_videos_channel(test_client, test_session, video_factory):
# Channels don't have videos yet
d = dict(channel_id=channel1.id)
request, response = test_client.post(f'/api/videos/search', content=json.dumps(d))
request, response = await async_client.post(f'/api/videos/search', content=json.dumps(d))
assert response.status_code == HTTPStatus.OK
assert len(response.json['file_groups']) == 0
@ -357,19 +373,19 @@ def test_search_videos_channel(test_client, test_session, video_factory):
session.add(vid2)
# Videos are gotten by their respective channels
request, response = test_client.post(f'/api/videos/search', content=json.dumps(d))
request, response = await async_client.post(f'/api/videos/search', content=json.dumps(d))
assert response.status_code == HTTPStatus.OK
assert len(response.json['file_groups']) == 1
assert response.json['totals']['file_groups'] == 1
assert_dict_contains(response.json['file_groups'][0],
dict(primary_path='vid2.mp4', video=dict(channel_id=channel1.id)))
dict(primary_path='foo/vid2.mp4', video=dict(channel_id=channel1.id)))
d = dict(channel_id=channel2.id)
request, response = test_client.post(f'/api/videos/search', content=json.dumps(d))
request, response = await async_client.post(f'/api/videos/search', content=json.dumps(d))
assert response.status_code == HTTPStatus.OK
assert len(response.json['file_groups']) == 1
assert_dict_contains(response.json['file_groups'][0],
dict(primary_path='vid1.mp4', video=dict(channel_id=channel2.id)))
dict(primary_path='bar/vid1.mp4', video=dict(channel_id=channel2.id)))
@pytest.mark.asyncio
@ -413,35 +429,37 @@ async def test_channel_download_id(async_client, test_session, tag_factory, simp
urls=['https://example.com/channel1'],
tag_names=[tag.name],
downloader=test_downloader.name,
settings=dict(channel_id=simple_channel.id),
settings=dict(),
collection_id=simple_channel.collection_id,
frequency=120,
)
request, response = await async_client.post(f'/api/download', json=body)
assert response.status_code == HTTPStatus.CREATED
download = test_session.query(Download).one()
assert download.url == 'https://example.com/channel1'
assert download.channel_id == simple_channel.id
assert download.collection_id == simple_channel.collection_id
# A Channel relationship can be removed from a Download.
# A Collection relationship can be removed from a Download.
body = dict(
urls=['https://example.com/channel1'],
tag_names=[tag.name],
downloader=test_downloader.name,
settings=dict(),
collection_id=None,
frequency=240,
)
request, response = await async_client.put(f'/api/download/{download.id}', json=body)
assert response.status_code == HTTPStatus.NO_CONTENT
download = test_session.query(Download).one()
assert download.channel_id is None
assert download.collection_id is None
assert download.frequency == 240
# A once-Download cannot be associated with a Channel.
# A once-Download cannot be associated with a Collection.
body = dict(
urls=['https://example.com/channel1'],
tag_names=[tag.name],
downloader=test_downloader.name,
settings=dict(channel_id=simple_channel.id),
collection_id=simple_channel.collection_id,
)
request, response = await async_client.put(f'/api/download/{download.id}', json=body)
assert response.status_code == HTTPStatus.BAD_REQUEST

View file

@ -8,7 +8,7 @@ from modules.videos.models import Video
from wrolpi.downloader import Download, DownloadFrequency
def test_delete_channel_no_url(test_session, test_client, channel_factory):
def test_delete_channel_no_url(test_session, channel_factory):
"""
A Channel can be deleted even if it has no URL.
"""
@ -104,8 +104,16 @@ async def test_channel_download_relationships(test_session, download_channel):
def test_channel_info_json(test_session, test_directory):
channel = Channel(name='new channel')
from wrolpi.collections import Collection
# Create Collection first without directory
collection = Collection(name='new channel', kind='channel')
test_session.add(collection)
test_session.flush()
channel = Channel(collection_id=collection.id)
test_session.add(channel)
test_session.flush()
with pytest.raises(FileNotFoundError) as e:
# Cannot get info json because directory is not defined.

View file

@ -20,7 +20,6 @@ from wrolpi.dates import seconds_to_timestamp
from wrolpi.db import get_db_session, get_db_curs
from wrolpi.vars import DEFAULT_FILE_PERMISSIONS
from .errors import ChannelNameConflict, ChannelURLConflict, ChannelDirectoryConflict, ChannelSourceIdConflict
from .models import Channel
logger = logger.getChild(__name__)
@ -52,6 +51,8 @@ def check_for_channel_conflicts(session: Session, id_=None, url=None, name=None,
"""
Search for any channels that conflict with the provided args, raise a relevant exception if any conflicts are found.
"""
from .models import Channel
if not any([id_, url, name, directory]):
raise Exception('Cannot search for channel with no arguments')
@ -68,11 +69,13 @@ def check_for_channel_conflicts(session: Session, id_=None, url=None, name=None,
if list(conflicts):
raise ChannelURLConflict()
if name:
conflicts = base_where.filter(Channel.name == name)
from wrolpi.collections import Collection
conflicts = base_where.join(Collection).filter(Collection.name == name)
if list(conflicts):
raise ChannelNameConflict()
if directory:
conflicts = base_where.filter(Channel.directory == directory)
from wrolpi.collections import Collection
conflicts = base_where.join(Collection).filter(Collection.directory == directory)
if list(conflicts):
raise ChannelDirectoryConflict()
if source_id:
@ -269,6 +272,8 @@ def extract_video_duration(video_path: Path) -> Optional[int]:
async def update_view_counts_and_censored(channel_id: int):
"""Update view_count for all Videos in a channel using its info_json file. Also sets FileGroup.censored
if Video is no longer available on the Channel."""
from .models import Channel
with get_db_session() as session:
channel: Channel = session.query(Channel).filter_by(id=channel_id).one()
channel_name = channel.name
@ -284,13 +289,14 @@ async def update_view_counts_and_censored(channel_id: int):
with get_db_curs(commit=True) as curs:
# Update the view_count for each video.
stmt = '''
WITH source AS (select * from json_to_recordset(%s::json) as (id text, view_count int))
UPDATE video
SET view_count = s.view_count
FROM source as s
WHERE source_id=s.id AND channel_id=%s
RETURNING video.id AS updated_ids
'''
WITH source AS (select * from json_to_recordset(%s::json) as (id text, view_count int))
UPDATE video
SET view_count = s.view_count
FROM source as s
WHERE source_id = s.id
AND channel_id = %s
RETURNING video.id AS updated_ids \
'''
curs.execute(stmt, (view_counts_str, channel_id))
count = len(curs.fetchall())
logger.info(f'Updated {count} view counts in DB for {channel_name}.')
@ -299,13 +305,13 @@ async def update_view_counts_and_censored(channel_id: int):
with get_db_curs(commit=True) as curs:
# Set FileGroup.censored if the video is no longer on the Channel.
stmt = '''
UPDATE file_group fg
SET censored = NOT (v.source_id = ANY(%(source_ids)s))
FROM video v
WHERE v.file_group_id = fg.id
AND v.channel_id = %(channel_id)s
RETURNING fg.id, fg.censored
'''
UPDATE file_group fg
SET censored = NOT (v.source_id = ANY (%(source_ids)s))
FROM video v
WHERE v.file_group_id = fg.id
AND v.channel_id = %(channel_id)s
RETURNING fg.id, fg.censored \
'''
curs.execute(stmt, {'channel_id': channel_id, 'source_ids': source_ids})
censored = len([i for i in curs.fetchall() if i['censored']])
logger.info(f'Set {censored} censored videos for {channel_name}.')

View file

@ -22,11 +22,23 @@ from wrolpi.vars import PROJECT_DIR
@pytest.fixture
def simple_channel(test_session, test_directory) -> Channel:
"""Get a Channel with the minimum properties. This Channel has no download!"""
channel = Channel(
directory=test_directory,
from wrolpi.collections import Collection
# Create Collection first
collection = Collection(
name='Simple Channel',
kind='channel',
directory=test_directory,
)
test_session.add(collection)
test_session.flush([collection])
# Create Channel linked to Collection
channel = Channel(
collection_id=collection.id,
url='https://example.com/channel1',
)
test_session.add(channel)
test_session.commit()
return channel
@ -36,21 +48,32 @@ def simple_channel(test_session, test_directory) -> Channel:
def channel_factory(test_session, test_directory):
"""Create a random Channel with a directory, and Download."""
from wrolpi.tags import Tag
from wrolpi.collections import Collection
def factory(source_id: str = None, download_frequency: DownloadFrequency = None, url: str = None, name: str = None,
directory: pathlib.Path = None, tag_name: str = None):
name = name or str(uuid4())
tag = Tag.find_by_name(tag_name) if tag_name else None
channel = Channel(
tag_name = tag.name if tag else None
directory = directory or format_videos_destination(name, tag_name, url or f'https://example.com/{name}')
directory.mkdir(exist_ok=True, parents=True)
# Create Collection first
collection = Collection(
name=name,
url=url or f'https://example.com/{name}',
source_id=source_id,
tag=tag,
kind='channel',
directory=directory,
tag_id=tag.id if tag else None,
)
tag_name = tag.name if tag else None
channel.directory = directory or format_videos_destination(name, tag_name, channel.url)
channel.directory.mkdir(exist_ok=True, parents=True)
test_session.add(collection)
test_session.flush([collection])
# Create Channel linked to Collection
channel = Channel(
collection_id=collection.id,
url=url or f'https://example.com/{name}',
source_id=source_id,
)
test_session.add(channel)
test_session.flush([channel])
test_session.add(Directory(path=channel.directory, name=channel.directory.name))
@ -68,11 +91,25 @@ def channel_factory(test_session, test_directory):
@pytest.fixture
def download_channel(test_session, test_directory, video_download_manager) -> Channel:
"""Get a test Channel that has a download frequency."""
# Add a frequency to the test channel, then give it a download.
channel = Channel(directory=test_directory, name='Download Channel', url='https://example.com/channel1',
source_id='channel1')
from wrolpi.collections import Collection
# Create Collection first
collection = Collection(
name='Download Channel',
kind='channel',
directory=test_directory,
)
test_session.add(collection)
test_session.flush([collection])
# Create Channel linked to Collection
channel = Channel(
collection_id=collection.id,
url='https://example.com/channel1',
source_id='channel1',
)
test_session.add(channel)
test_session.flush([channel, ])
test_session.flush([channel])
assert channel and channel.id and channel.url
download = channel.get_or_create_download(channel.url, 60, test_session)
assert download.url == channel.url

View file

@ -5,7 +5,6 @@ import logging
import os.path
import pathlib
import re
import sys
import traceback
from abc import ABC
from datetime import timedelta
@ -208,7 +207,7 @@ class ChannelDownloader(Downloader, ABC):
# The settings to send to the VideoDownloader.
settings = dict()
if channel:
settings.update(dict(channel_id=channel.id, channel_url=download.url))
settings.update(dict(collection_id=channel.collection_id, channel_url=download.url))
if download.destination:
destination = get_absolute_media_path(download.destination)
settings['destination'] = str(destination) # Need str for JSON conversion
@ -622,14 +621,20 @@ class VideoDownloader(Downloader, ABC):
except UnknownChannel:
# Destination must not be a channel.
pass
channel_id = download.channel_id
# Look up Channel via Collection or channel_url from settings
collection_id = download.collection_id
channel_url = settings.get('channel_url')
if not channel and (channel_id or channel_url):
if not channel and (collection_id or channel_url):
# Could not find Channel via yt-dlp info_json, use info from ChannelDownloader if it created this Download.
logger.info(f'Using download.settings to find channel')
try:
channel = get_channel(channel_id=channel_id, url=channel_url, return_dict=False)
logger.debug(f'Found channel with channel url {channel=}')
# Find Channel by collection_id or URL
if collection_id:
with get_db_session() as session:
channel = session.query(Channel).filter_by(collection_id=collection_id).one_or_none()
if not channel and channel_url:
channel = get_channel(url=channel_url, return_dict=False)
logger.debug(f'Found channel with collection_id or channel url {channel=}')
except UnknownChannel:
# We do not need a Channel if we have a destination directory.
if not destination:

View file

@ -252,7 +252,6 @@ class ChannelsConfig(ConfigFile):
self.update({'channels': value})
def import_config(self, file: pathlib.Path = None, send_events=False):
from modules.videos.channel.lib import get_channel
super().import_config()
try:
channels = self.channels
@ -268,31 +267,8 @@ class ChannelsConfig(ConfigFile):
for option in (i for i in REQUIRED_OPTIONS if i not in data):
raise ConfigError(f'Channel "{directory}" is required to have "{option}"')
# Try to find Channel by directory because it is unique.
channel = Channel.get_by_path(directory, session)
if not channel:
try:
# Try to find Channel using other attributes before creating new Channel.
channel = get_channel(
session,
source_id=data.get('source_id'),
url=data.get('url'),
directory=str(directory),
return_dict=False,
)
except UnknownChannel:
# Channel not yet in the DB, add it.
channel = Channel(directory=directory)
session.add(channel)
channel.flush()
# TODO refresh the files in the channel.
logger.warning(f'Creating new Channel from config: {directory}')
# Copy existing channel data, update all values from the config. This is necessary to clear out
# values not in the config.
full_data = channel.dict()
full_data.update(data)
channel.update(full_data)
# Create or update Channel from config (handles Collection creation)
channel = Channel.from_config(data, session=session)
updated_channel_ids.add(channel.id)
if not channel.source_id and channel.url and flags.have_internet.is_set():
@ -519,8 +495,9 @@ def set_test_downloader_config(enabled: bool):
def get_channels_config_from_db(session: Session) -> dict:
"""Create a dictionary that contains all the Channels from the DB."""
channels = session.query(Channel).order_by(Channel.directory).all()
channels = sorted((i.config_view() for i in channels), key=lambda i: i['directory'])
from wrolpi.collections import Collection
channels = session.query(Channel).join(Collection).order_by(Collection.directory).all()
channels = sorted((i.config_view() for i in channels), key=lambda i: i['directory'] if i['directory'] else '')
return dict(channels=channels)
@ -572,9 +549,12 @@ def import_channels_config():
def link_channel_and_downloads(session: Session, channel_: Type[Base] = Channel, download_: Type[Base] = Download):
"""Create any missing Downloads for any Channel.url/Channel.directory that has a Download. Associate any Download
related to a Channel."""
# Only Downloads with a frequency can be a Channel Download.
"""Associate any Download related to a Channel via its Collection.
Downloads are linked to Collections (via collection_id) rather than directly to Channels.
This function finds Channels and links their Downloads to the Channel's Collection.
"""
# Only Downloads with a frequency can be a Collection Download.
downloads = list(session.query(download_).filter(download_.frequency.isnot(None)).all())
# Download.url is unique and cannot be null.
downloads_by_url = {i.url: i for i in downloads}
@ -586,26 +566,27 @@ def link_channel_and_downloads(session: Session, channel_: Type[Base] = Channel,
for channel in channels:
directory = str(channel.directory)
for download in downloads_with_destination:
if download.settings['destination'] == directory and not download.channel_id:
download.channel_id = channel.id
if download.settings['destination'] == directory and not download.collection_id:
download.collection_id = channel.collection_id
need_commit = True
download = downloads_by_url.get(channel.url)
if download and not download.channel_id:
download.channel_id = channel.id
if download and not download.collection_id:
download.collection_id = channel.collection_id
need_commit = True
# Get any Downloads for a Channel's RSS feed.
rss_url = channel.get_rss_url()
if rss_url and (download := downloads_by_url.get(rss_url)):
download.channel_id = channel.id
need_commit = True
if not download.collection_id:
download.collection_id = channel.collection_id
need_commit = True
# Associate any Download which shares a Channel's URL.
for download in downloads:
channel = channel_.get_by_url(download.url, session)
if channel and not download.channel_id:
download.channel_id = channel.id
if channel and not download.collection_id:
download.collection_id = channel.collection_id
need_commit = True
if need_commit:
@ -676,10 +657,11 @@ async def get_statistics():
if monthly_videos else 0
curs.execute('''
SELECT COUNT(c.id) AS "channels",
COUNT(c.id) FILTER ( WHERE c.tag_id IS NOT NULL ) AS "tagged_channels"
SELECT COUNT(c.id) AS "channels",
COUNT(c.id) FILTER ( WHERE col.tag_id IS NOT NULL ) AS "tagged_channels"
FROM channel c
LEFT JOIN public.tag t on t.id = c.tag_id
LEFT JOIN collection col on col.id = c.collection_id
LEFT JOIN public.tag t on t.id = col.tag_id
''')
channel_stats = dict(curs.fetchone())
ret = dict(statistics=dict(

View file

@ -531,9 +531,8 @@ class Video(ModelHelper, Base):
class Channel(ModelHelper, Base):
__tablename__ = 'channel'
id = Column(Integer, primary_key=True)
name = Column(String)
# name and directory are stored in Collection, accessed via properties
url = Column(String, unique=True) # will only be downloaded if related Download exists.
directory: pathlib.Path = Column(MediaPathType)
generate_posters = Column(Boolean, default=False) # generating posters may delete files, and can be slow.
calculate_duration = Column(Boolean, default=True) # use ffmpeg to extract duration (slower than info json).
download_missing_data = Column(Boolean, default=True) # fetch missing data like `source_id` and video comments.
@ -549,16 +548,65 @@ class Channel(ModelHelper, Base):
info_date = Column(Date)
videos: InstrumentedList = relationship('Video', primaryjoin='Channel.id==Video.channel_id')
downloads: InstrumentedList = relationship('Download', primaryjoin='Download.channel_id==Channel.id')
tag_id = Column(Integer, ForeignKey('tag.id', ondelete='CASCADE'))
tag = relationship('Tag', primaryjoin='Channel.tag_id==Tag.id')
collection_id = Column(Integer, ForeignKey('collection.id', ondelete='CASCADE'))
collection = relationship('Collection', foreign_keys=[collection_id])
@property
def downloads(self) -> InstrumentedList:
"""Get downloads from associated Collection."""
return self.collection.downloads if self.collection else []
def __repr__(self):
return f'<Channel id={self.id} name={repr(self.name)} url={self.url} directory={self.directory}>'
@property
def name(self) -> str | None:
"""Delegate name to Collection"""
return self.collection.name if self.collection else None
@name.setter
def name(self, value: str):
"""Set name on Collection"""
if self.collection:
self.collection.name = value
@property
def directory(self) -> pathlib.Path | None:
"""Delegate directory to Collection"""
return self.collection.directory if self.collection else None
@directory.setter
def directory(self, value: pathlib.Path | str):
"""Set directory on Collection"""
if self.collection:
self.collection.directory = value
@property
def tag(self):
"""Delegate tag to Collection"""
return self.collection.tag if self.collection else None
@tag.setter
def tag(self, value):
"""Set tag on Collection"""
if self.collection:
self.collection.tag = value
@property
def tag_id(self) -> int | None:
"""Delegate tag_id to Collection"""
return self.collection.tag_id if self.collection else None
@tag_id.setter
def tag_id(self, value: int):
"""Set tag_id on Collection"""
if self.collection:
self.collection.tag_id = value
@property
def tag_name(self) -> str | None:
return self.tag.name if self.tag else None
"""Delegate tag_name to Collection"""
return self.collection.tag_name if self.collection else None
@property
def location(self) -> str:
@ -597,7 +645,7 @@ class Channel(ModelHelper, Base):
data = data.copy()
# URL should not be empty string.
url = data.pop('url')
url = data.pop('url', None)
self.url = url or None
session: Session = Session.object_session(self)
@ -637,7 +685,7 @@ class Channel(ModelHelper, Base):
continue
download = self.get_or_create_download(url, frequency, session=session, reset_attempts=True)
download.channel_id = channel_id
download.collection_id = self.collection_id
session.add(download)
def config_view(self) -> dict:
@ -657,13 +705,62 @@ class Channel(ModelHelper, Base):
)
return config
@classmethod
def from_config(cls, data: dict, session: Session = None) -> 'Channel':
"""
Create or update a Channel from config data. This also creates/updates the Collection.
Args:
data: Config dict containing channel metadata (name, directory, url, source_id, etc.)
session: Database session
Returns:
The created or updated Channel
"""
from wrolpi.collections import Collection
from wrolpi.db import get_db_session
if session is None:
session = get_db_session()
# Ensure this is treated as a channel collection
data = data.copy()
data['kind'] = 'channel'
# Create or update the Collection first
collection = Collection.from_config(data, session=session)
# Extract Channel-specific fields
url = data.get('url')
source_id = data.get('source_id')
# Find existing Channel by collection_id or create new one
channel = session.query(cls).filter_by(collection_id=collection.id).one_or_none()
if not channel:
# Create new Channel
channel = cls(
collection_id=collection.id,
url=url,
source_id=source_id,
)
session.add(channel)
session.flush([channel])
# Use the update() method to handle all fields including downloads
channel.update(data)
return channel
@staticmethod
def get_by_path(path: pathlib.Path, session: Session) -> Optional['Channel']:
if not path:
raise RuntimeError('Must provide path to get Channel')
path = pathlib.Path(path) if isinstance(path, str) else path
path = str(path.absolute()) if path.is_absolute() else str(get_media_directory() / path)
channel = session.query(Channel).filter_by(directory=path).one_or_none()
# Query through Collection relationship since directory is now on Collection
from wrolpi.collections import Collection
channel = session.query(Channel).join(Collection).filter(Collection.directory == path).one_or_none()
return channel
def __json__(self) -> dict:
@ -679,6 +776,8 @@ class Channel(ModelHelper, Base):
def dict(self, with_statistics: bool = False, with_downloads: bool = True) -> dict:
d = super(Channel, self).dict()
# Add Collection-delegated properties
d['name'] = self.name
d['tag_name'] = self.tag_name
d['rss_url'] = self.get_rss_url()
d['directory'] = \
@ -777,26 +876,17 @@ class Channel(ModelHelper, Base):
reset_attempts: bool = False) -> Download:
"""Get a Download record, if it does not exist, create it. Create a Download if necessary
which goes into this Channel's directory.
"""
if not isinstance(url, str) or not url:
raise InvalidDownload(f'Cannot get Download without url')
if not frequency:
raise InvalidDownload('Download for Channel must have a frequency')
Delegates to Collection.get_or_create_download() with Channel-specific downloaders.
"""
from modules.videos.downloader import ChannelDownloader, VideoDownloader
download = Download.get_by_url(url, session=session)
if not download:
download = download_manager.recurring_download(url, frequency, ChannelDownloader.name, session=session,
sub_downloader_name=VideoDownloader.name,
destination=self.directory,
reset_attempts=reset_attempts,
)
if reset_attempts:
download.attempts = 0
download.channel_id = self.id
return download
return self.collection.get_or_create_download(
url, frequency, session=session,
reset_attempts=reset_attempts,
downloader_name=ChannelDownloader.name,
sub_downloader_name=VideoDownloader.name
)
def get_rss_url(self) -> str | None:
"""Return the RSS Feed URL for this Channel, if any is possible."""

View file

@ -43,8 +43,9 @@ async def test_refresh_videos_index(async_client, test_session, test_directory,
assert not video.file_group.d_text, 'Video captions were not removed'
def test_refresh_videos(test_client, test_session, test_directory, simple_channel, video_factory, video_file_factory,
image_bytes_factory):
@pytest.mark.asyncio
async def test_refresh_videos(async_client, test_session, test_directory, simple_channel, video_factory,
video_file_factory, image_bytes_factory):
subdir = test_directory / 'subdir'
subdir.mkdir()
@ -81,7 +82,7 @@ def test_refresh_videos(test_client, test_session, test_directory, simple_channe
stmt = "INSERT INTO video (file_group_id) values (%(video_id)s)"
curs.execute(stmt, {'video_id': str(video4_id)})
test_client.post('/api/files/refresh')
await async_client.post('/api/files/refresh')
test_session.expire_all()
@ -104,12 +105,13 @@ def test_refresh_videos(test_client, test_session, test_directory, simple_channe
# Remove video1's poster, video1 should be updated.
video1_video_path = str(video1.video_path)
video1.poster_path.unlink()
test_client.post('/api/files/refresh')
await async_client.post('/api/files/refresh')
video1 = Video.get_by_path(video1_video_path, test_session)
assert not video1.poster_path
def test_channels_with_videos(test_session, test_client, test_directory, channel_factory, video_factory):
@pytest.mark.asyncio
async def test_channels_with_videos(test_session, async_client, test_directory, channel_factory, video_factory):
channel1 = channel_factory('channel1', name='channel1')
channel2 = channel_factory('channel2', name='channel2')
vid1 = video_factory(channel_id=channel1.id, with_video_file=True)
@ -132,10 +134,12 @@ def test_channels_with_videos(test_session, test_client, test_directory, channel
assert vid2_path.is_file(), 'Video file was deleted'
assert vid3_path.is_file(), 'Video file was deleted'
test_client.post('/api/files/refresh')
await async_client.post('/api/files/refresh')
assert test_session.query(Video).count() == 3, 'Did not find correct amount of video files.'
assert {i[0] for i in test_session.query(Channel.name)} == {'channel1', 'channel2'}, 'Channels were changed.'
from wrolpi.collections import Collection
assert {i[0] for i in test_session.query(Collection.name).join(Channel).filter(Collection.kind == 'channel')} == {
'channel1', 'channel2'}, 'Channels were changed.'
vid1 = Video.get_by_path(vid1_path)
vid2 = Video.get_by_path(vid2_path)
@ -146,11 +150,12 @@ def test_channels_with_videos(test_session, test_client, test_directory, channel
assert vid3.channel is None
def test_api_download(test_session, test_client, test_directory):
@pytest.mark.asyncio
async def test_api_download(test_session, async_client, test_directory):
"""A video can be downloaded."""
content = dict(urls=['https://example.com/video1', ], downloader='video', destination='dest',
settings=dict(excluded_urls='example.com'))
request, response = test_client.post('/api/download', content=json.dumps(content))
request, response = await async_client.post('/api/download', content=json.dumps(content))
assert response.status_code == HTTPStatus.CREATED
download = test_session.query(Download).one()
@ -364,7 +369,8 @@ async def test_video_file_format(async_client, test_session, fake_now):
@pytest.mark.asyncio
async def test_video_upload_file_tracking(test_session, async_client, video_factory, await_switches, make_multipart_form,
async def test_video_upload_file_tracking(test_session, async_client, video_factory, await_switches,
make_multipart_form,
image_bytes_factory):
"""Test that uploading info.json, poster, and caption files via /api/files/upload properly tracks them in Video and FileGroup."""
from wrolpi.common import get_relative_to_media_directory

View file

@ -257,6 +257,11 @@ async def test_import_channel_delete_missing_channels(await_switches, test_sessi
channel1 = channel_factory(source_id='foo')
channel2 = channel_factory(source_id='bar')
test_session.commit()
# Capture directory values before deletion (channels will be detached after deletion)
channel1_dir = str(channel1.directory)
channel2_dir = str(channel2.directory)
# Write Channels to the config file.
save_channels_config()
test_session.delete(channel1)
@ -267,29 +272,29 @@ async def test_import_channel_delete_missing_channels(await_switches, test_sessi
# Importing the config creates two Channels.
import_channels_config()
assert len(test_session.query(Channel).all()) == 2
assert str(channel1.directory) in test_channels_config.read_text()
assert str(channel2.directory) in test_channels_config.read_text()
assert channel1_dir in test_channels_config.read_text()
assert channel2_dir in test_channels_config.read_text()
# Delete channel2 from the config file.
config = get_channels_config()
config_dict = config.dict()
config_dict['channels'] = [i for i in config.channels if i['directory'] != str(channel2.directory)]
config_dict['channels'] = [i for i in config.channels if i['directory'] != channel2_dir]
config.update(config_dict)
await await_switches()
assert str(channel1.directory) in test_channels_config.read_text()
assert str(channel2.directory) not in test_channels_config.read_text()
assert channel1_dir in test_channels_config.read_text()
assert channel2_dir not in test_channels_config.read_text()
# Importing the config deletes the Channel record.
import_channels_config()
assert len(test_session.query(Channel).all()) == 1
assert str(channel1.directory) in test_channels_config.read_text()
assert str(channel2.directory) not in test_channels_config.read_text()
assert channel1_dir in test_channels_config.read_text()
assert channel2_dir not in test_channels_config.read_text()
# Saving and importing does not change anything.
save_channels_config()
import_channels_config()
assert str(channel1.directory) in test_channels_config.read_text()
assert str(channel2.directory) not in test_channels_config.read_text()
assert channel1_dir in test_channels_config.read_text()
assert channel2_dir not in test_channels_config.read_text()
@pytest.mark.asyncio

View file

@ -173,7 +173,7 @@ async def test_download_channel(test_session, test_directory, simple_channel, vi
downloads = video_download_manager.get_once_downloads(test_session)
assert {i.url for i in downloads} == {'https://youtube.com/watch?v=video_2_url'}
assert downloads[0].settings == {
'channel_id': 1,
'collection_id': simple_channel.collection_id,
'channel_url': 'https://www.youtube.com/c/LearningSelfReliance/videos',
'destination': str(test_directory),
}
@ -191,7 +191,7 @@ async def test_download_channel(test_session, test_directory, simple_channel, vi
downloads = video_download_manager.get_once_downloads(test_session)
assert {i.url for i in downloads} == {'https://youtube.com/watch?v=video_1_url'}
assert downloads[0].settings == {
'channel_id': 1,
'collection_id': simple_channel.collection_id,
'channel_url': 'https://www.youtube.com/c/LearningSelfReliance/videos',
'destination': str(test_directory),
}
@ -203,13 +203,23 @@ async def test_get_or_create_channel(async_client, test_session, test_directory,
Attempt to use an existing Channel if we can match it.
"""
from wrolpi.collections import Collection
one, two = await tag_factory(), await tag_factory()
c1 = Channel(name='foo', source_id='foo', url='https://example.com')
c2 = Channel(name='bar', source_id='bar')
c3 = Channel(name='baz', source_id='baz', url='https://example.net')
c4 = Channel(name='qux')
test_session.add_all([c1, c2, c3, c4])
# Create Collections for each Channel
def create_channel_with_collection(name, source_id=None, url=None):
collection = Collection(name=name, kind='channel')
test_session.add(collection)
test_session.flush([collection])
channel = Channel(collection_id=collection.id, source_id=source_id, url=url)
test_session.add(channel)
test_session.flush([channel])
return channel
c1 = create_channel_with_collection('foo', source_id='foo', url='https://example.com')
c2 = create_channel_with_collection('bar', source_id='bar')
c3 = create_channel_with_collection('baz', source_id='baz', url='https://example.net')
c4 = create_channel_with_collection('qux')
test_session.commit()
# All existing channels should be used.
@ -549,7 +559,8 @@ def test_normalize_video_file_names(test_directory, video_download_manager):
@pytest.mark.asyncio
async def test_video_download_cookies(test_session, test_directory, mock_video_extract_info, await_switches,
video_download_manager, mock_video_process_runner, image_file, test_videos_downloader_config):
video_download_manager, mock_video_process_runner, image_file,
test_videos_downloader_config):
config = get_videos_downloader_config()
config.browser_profile = str(test_directory / 'firefox/some directory')
@ -577,7 +588,8 @@ async def test_video_download_cookies(test_session, test_directory, mock_video_e
@pytest.mark.asyncio
async def test_video_download_always_use_cookies(test_session, test_directory, mock_video_extract_info, await_switches,
video_download_manager, mock_video_process_runner, image_file, test_videos_downloader_config):
video_download_manager, mock_video_process_runner, image_file,
test_videos_downloader_config):
config = get_videos_downloader_config()
config.always_use_browser_profile = True

View file

@ -191,13 +191,13 @@ def test_link_channel_and_downloads(test_session, channel_factory, test_download
test_session.add_all([download1, download2, download3])
test_session.commit()
assert test_session.query(Download).count() == 3
assert not any(i.channel_id for i in test_download_manager.get_downloads(test_session))
assert not any(i.collection_id for i in test_download_manager.get_downloads(test_session))
# `link_channel_and_downloads` creates missing Downloads.
# `link_channel_and_downloads` links Downloads to Collections.
lib.link_channel_and_downloads(session=test_session)
assert test_session.query(Download).count() == 3
assert all(i.channel_id for i in test_download_manager.get_recurring_downloads(test_session))
assert not any(i.channel_id for i in test_download_manager.get_once_downloads(test_session))
assert all(i.collection_id for i in test_download_manager.get_recurring_downloads(test_session))
assert not any(i.collection_id for i in test_download_manager.get_once_downloads(test_session))
@pytest.mark.asyncio
@ -233,13 +233,13 @@ def test_link_channel_and_downloads_migration(async_client, test_session, channe
assert test_session.query(Download).count() == 4
d1, d2a, d2b, d2c = test_session.query(Download).order_by(Download.url).all()
assert d1.url == channel1.url == 'https://example.com/channel1' and d1.frequency == 1
assert d1.channel_id
assert d1.collection_id == channel1.collection_id
assert d2a.url == 'https://example.com/channel2' and d2a.frequency == 1
assert d2a.channel_id
assert d2a.collection_id == channel2.collection_id
assert d2b.url == 'https://example.com/channel2/rss' and d2b.frequency == 1
assert d2b.channel_id
assert d2b.collection_id == channel2.collection_id
assert d2c.url == 'https://example.com/channel2/video/1' and d2c.frequency is None
assert not d2c.channel_id
assert not d2c.collection_id
@pytest.mark.asyncio

View file

@ -111,12 +111,11 @@ def get_all_entries_tags():
},
"""
stmt = '''
SELECT tz.zim_id, tz.zim_entry, array_agg(t.name)::TEXT[]
FROM
tag t
LEFT JOIN tag_zim tz on t.id = tz.tag_id
GROUP BY 1, 2
'''
SELECT tz.zim_id, tz.zim_entry, array_agg(t.name)::TEXT[]
FROM tag t
LEFT JOIN tag_zim tz on t.id = tz.tag_id
GROUP BY 1, 2 \
'''
with get_db_curs() as curs:
curs.execute(stmt)
entries = dict()

View file

@ -133,18 +133,19 @@ async def test_zim_tag_and_untag(async_client, test_session, test_zim, tag_facto
assert response.status_code == HTTPStatus.NOT_FOUND
def test_zim_subscribe(test_session, test_client):
@pytest.mark.asyncio
async def test_zim_subscribe(test_session, async_client):
"""A Kiwix subscription can be scheduled in the API. The language can be changed."""
# Subscribe to English.
content = dict(name='Wikipedia (mini)', language='en')
request, response = test_client.post('/api/zim/subscribe', content=json.dumps(content))
request, response = await async_client.post('/api/zim/subscribe', content=json.dumps(content))
assert response.status_code == HTTPStatus.CREATED
download: Download = test_session.query(Download).one()
assert download.url == 'https://download.kiwix.org/zim/wikipedia/wikipedia_en_all_mini_'
assert download.info_json == {'language': 'en', 'name': 'Wikipedia (mini)'}
assert download.frequency
request, response = test_client.get('/api/zim/subscribe')
request, response = await async_client.get('/api/zim/subscribe')
assert response.status_code == HTTPStatus.OK
assert response.json['subscriptions'] == {
'Wikipedia (mini)': {'download_id': 1,
@ -155,7 +156,7 @@ def test_zim_subscribe(test_session, test_client):
# Change subscription to French.
content = dict(name='Wikipedia (mini)', language='fr')
request, response = test_client.post('/api/zim/subscribe', content=json.dumps(content))
request, response = await async_client.post('/api/zim/subscribe', content=json.dumps(content))
assert response.status_code == HTTPStatus.CREATED
assert test_session.query(Download).count() == 1
@ -163,7 +164,7 @@ def test_zim_subscribe(test_session, test_client):
assert download.url == 'https://download.kiwix.org/zim/wikipedia/wikipedia_fr_all_mini_'
assert download.info_json == {'language': 'fr', 'name': 'Wikipedia (mini)'}
assert download.frequency
request, response = test_client.get('/api/zim/subscribe')
request, response = await async_client.get('/api/zim/subscribe')
assert response.status_code == HTTPStatus.OK
assert response.json['subscriptions'] == {
'Wikipedia (mini)': {'download_id': 1,
@ -173,9 +174,9 @@ def test_zim_subscribe(test_session, test_client):
'name': 'Wikipedia (mini)'}}
# Delete subscription.
request, response = test_client.delete('/api/zim/subscribe/1')
request, response = await async_client.delete('/api/zim/subscribe/1')
assert response.status_code == HTTPStatus.NO_CONTENT
request, response = test_client.get('/api/zim/subscribe')
request, response = await async_client.get('/api/zim/subscribe')
assert response.status_code == HTTPStatus.OK
assert response.json['subscriptions'] == {}
# No subscriptions or downloads.
@ -184,17 +185,18 @@ def test_zim_subscribe(test_session, test_client):
# English Stack Exchange is special.
content = dict(name='Stackoverflow (Stack Exchange)', language='en')
request, response = test_client.post('/api/zim/subscribe', content=json.dumps(content))
request, response = await async_client.post('/api/zim/subscribe', content=json.dumps(content))
assert response.status_code == HTTPStatus.CREATED
download: Download = test_session.query(Download).one()
assert download.url == 'https://download.kiwix.org/zim/stack_exchange/stackoverflow.com_en_all_'
def test_get_zim_entry(test_session, test_client, test_zim):
request, response = test_client.get('/api/zim/1/entry/home')
@pytest.mark.asyncio
async def test_get_zim_entry(test_session, async_client, test_zim):
request, response = await async_client.get('/api/zim/1/entry/home')
assert response.status_code == HTTPStatus.OK
request, response = test_client.get('/api/zim/1/entry/does not exist')
request, response = await async_client.get('/api/zim/1/entry/does not exist')
assert response.status_code == HTTPStatus.NOT_FOUND

View file

@ -43,6 +43,7 @@ cp /opt/wrolpi/etc/raspberrypios/nginx.conf /etc/nginx/nginx.conf
[ -f /etc/nginx/conf.d/default.conf ] && rm /etc/nginx/conf.d/default.conf
cp /opt/wrolpi/etc/raspberrypios/wrolpi.conf /etc/nginx/conf.d/wrolpi.conf
cp /opt/wrolpi/etc/raspberrypios/50x.html /var/www/50x.html
cp /opt/wrolpi/etc/raspberrypios/maintenance.html /var/www/maintenance.html
# Generate nginx certificate for HTTPS.
if [[ ! -f /etc/nginx/cert.crt || ! -f /etc/nginx/cert.key ]]; then

View file

@ -28,4 +28,4 @@ srt==3.5.3
tqdm==4.67.1
vininfo[cli]==1.8.0
webvtt-py==0.5.1
yt-dlp==2025.09.26
yt-dlp==2025.11.12

View file

@ -1,8 +1,6 @@
import asyncio
import json
import logging
import multiprocessing
import sys
from asyncio import CancelledError
from datetime import datetime, timezone, date
from decimal import Decimal

View file

@ -3,7 +3,7 @@ import pathlib
import subprocess
import tempfile
from pathlib import Path
from typing import Generator, Union, Optional
from typing import Generator, Union
import srt
import webvtt

View file

@ -169,9 +169,11 @@ async def run_command(cmd: tuple[str | pathlib.Path, ...], cwd: pathlib.Path | s
stderr = stderr_file.read_bytes() or b''
# Logs details of the call, but only if it took a long time or TRACE is enabled.
if log_command:
logger.debug(f'run_command: finished ({elapsed=}s) with stdout={len(stdout)} stderr={len(stderr)}: {cmd_str=}')
logger.debug(
f'run_command: finished ({elapsed=}s) with stdout={len(stdout)} stderr={len(stderr)}: {cmd_str=}')
elif logger.isEnabledFor(TRACE_LEVEL):
logger.trace(f'run_command: finished ({elapsed=}s) with stdout={len(stdout)} stderr={len(stderr)}: {cmd_str=}')
logger.trace(
f'run_command: finished ({elapsed=}s) with stdout={len(stdout)} stderr={len(stderr)}: {cmd_str=}')
return CommandResult(
return_code=proc.returncode,
cancelled=cancelled,

View file

@ -0,0 +1,16 @@
from . import lib
from .config import (collections_config, CollectionsConfig, save_collections_config)
from .errors import UnknownCollection
from .models import Collection, CollectionItem
from .types import collection_type_registry
__all__ = [
'Collection',
'CollectionItem',
'CollectionsConfig',
'UnknownCollection',
'collection_type_registry',
'collections_config',
'lib',
'save_collections_config',
]

261
wrolpi/collections/api.py Normal file
View file

@ -0,0 +1,261 @@
"""
Collection API Endpoints
Unified REST API for all collection types (domains, channels, playlists, etc.).
"""
from http import HTTPStatus
from sanic import Request, Blueprint, response
from sanic_ext.extensions.openapi import openapi
from wrolpi.api_utils import json_response
from wrolpi.common import wrol_mode_check
from wrolpi.schema import JSONErrorResponse
from . import lib, schema
from .errors import UnknownCollection
# Create blueprint
collection_bp = Blueprint('Collection', url_prefix='/api/collections')
@collection_bp.get('/')
@openapi.summary('Get all collections')
@openapi.parameter('kind', str, 'query', description='Filter by collection kind (e.g., domain, channel)',
required=False)
@openapi.response(HTTPStatus.OK, description="List of collections with metadata")
async def get_collections_endpoint(request: Request):
"""
Get all collections, optionally filtered by kind.
This unified endpoint works for all collection types. Use the 'kind' query parameter
to filter by collection type (e.g., ?kind=domain or ?kind=channel).
Examples:
GET /api/collections - Get all collections
GET /api/collections?kind=domain - Get only domain collections
GET /api/collections?kind=channel - Get only channel collections
Returns:
- collections: List of collection objects
- totals: Count of collections
- metadata: UI metadata (columns, fields, routes, messages) if kind is specified
"""
kind = request.args.get('kind')
collections = lib.get_collections(kind=kind)
# Include metadata if a specific kind is requested
response_data = {
'collections': collections,
'totals': {'collections': len(collections)}
}
if kind:
response_data['metadata'] = lib.get_collection_metadata(kind)
return json_response(response_data)
@collection_bp.get('/<collection_id:int>')
@openapi.summary('Get a single collection by ID')
@openapi.response(HTTPStatus.OK, description="Collection details with statistics")
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_collection_endpoint(_: Request, collection_id: int):
"""
Get details for a single collection including type-specific statistics.
Returns collection metadata plus statistics specific to the collection type:
- Domain collections: includes url_count and size
- Channel collections: includes video_count and size
- Other types: includes item_count and total_size
"""
try:
collection_data = lib.get_collection_with_stats(collection_id)
return json_response({'collection': collection_data})
except UnknownCollection as e:
return json_response({'error': str(e)}, status=HTTPStatus.NOT_FOUND)
@collection_bp.put('/<collection_id:int>')
@openapi.definition(
summary='Update a collection',
body=schema.CollectionUpdateRequest,
validate=True,
)
@openapi.response(HTTPStatus.OK, description="Collection updated successfully")
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
@wrol_mode_check
async def put_collection_endpoint(_: Request, collection_id: int, body: schema.CollectionUpdateRequest):
"""
Update collection properties (directory, tag, description).
This endpoint works for all collection types. You can update:
- directory: Set or clear the collection's directory restriction
- tag_name: Set or clear the collection's tag (requires directory)
- description: Set or update the collection's description
Note: To clear a field, pass an empty string. To leave unchanged, omit the field.
"""
try:
collection = lib.update_collection(
collection_id=collection_id,
directory=body.directory,
tag_name=body.tag_name,
description=body.description
)
# Return updated collection data
collection_data = lib.get_collection_with_stats(collection_id)
return json_response({'collection': collection_data})
except UnknownCollection as e:
return json_response({'error': str(e)}, status=HTTPStatus.NOT_FOUND)
except Exception as e:
return json_response({'error': str(e)}, status=HTTPStatus.BAD_REQUEST)
@collection_bp.post('/<collection_id:int>/refresh')
@openapi.summary('Refresh files in collection directory')
@openapi.response(HTTPStatus.OK, description="Collection refresh started")
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
@wrol_mode_check
async def refresh_collection_endpoint(_: Request, collection_id: int):
"""
Queue a refresh for a collection's directory.
This scans the collection's directory for new or modified files and updates
the database accordingly. Only works for directory-restricted collections.
The refresh happens asynchronously in the background.
"""
try:
lib.refresh_collection(collection_id, send_events=True)
return json_response({'message': 'Collection refresh started'})
except UnknownCollection as e:
return json_response({'error': str(e)}, status=HTTPStatus.NOT_FOUND)
except Exception as e:
return json_response({'error': str(e)}, status=HTTPStatus.BAD_REQUEST)
@collection_bp.post('/<collection_id:int>/tag')
@openapi.definition(
summary='Tag a collection and optionally move files',
body=schema.CollectionTagRequest,
validate=True,
)
@openapi.response(HTTPStatus.OK, schema.CollectionTagResponse, description="Collection tagged successfully")
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
@wrol_mode_check
async def tag_collection_endpoint(_: Request, collection_id: int, body: schema.CollectionTagRequest):
"""
Tag a collection and optionally move its files to a new directory.
Tagging a collection:
1. Creates or assigns the specified tag
2. Optionally moves the collection to a new directory
3. Updates the collection's metadata
If no directory is specified, the collection must already have one.
"""
try:
result = lib.tag_collection(
collection_id=collection_id,
tag_name=body.tag_name,
directory=body.directory
)
return json_response(result)
except UnknownCollection as e:
return json_response({'error': str(e)}, status=HTTPStatus.NOT_FOUND)
except Exception as e:
return json_response({'error': str(e)}, status=HTTPStatus.BAD_REQUEST)
@collection_bp.post('/<collection_id:int>/tag_info')
@openapi.definition(
summary='Get tag information for a collection',
body=schema.CollectionTagInfoRequest,
validate=True,
)
@openapi.response(HTTPStatus.OK, schema.CollectionTagInfoResponse, description="Tag info retrieved successfully")
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
async def get_tag_info_endpoint(_: Request, collection_id: int, body: schema.CollectionTagInfoRequest):
"""
Get information about tagging a collection with a specific tag.
Returns the suggested directory path and checks for conflicts with existing collections.
Domain collections cannot share directories with other domain collections,
but can share with channel collections.
This is useful for showing users the suggested directory before they commit to tagging.
"""
try:
tag_info = lib.get_tag_info(
collection_id=collection_id,
tag_name=body.tag_name
)
return json_response(tag_info)
except UnknownCollection as e:
return json_response({'error': str(e)}, status=HTTPStatus.NOT_FOUND)
@collection_bp.delete('/<collection_id:int>')
@openapi.definition(
summary='Delete a collection',
)
@openapi.response(HTTPStatus.NO_CONTENT, description="Collection deleted successfully")
@openapi.response(HTTPStatus.NOT_FOUND, JSONErrorResponse)
@wrol_mode_check
def delete_collection_endpoint(_: Request, collection_id: int):
"""
Delete a collection and orphan its child items.
For domain collections:
- Orphans child Archives (sets collection_id to NULL)
- Deletes the Collection record
- Archives remain in the database but are no longer associated with a domain
- Triggers domain config save
"""
try:
collection = lib.delete_collection(collection_id=collection_id)
from wrolpi.events import Events
Events.send_deleted(f'Deleted {collection["kind"]} collection: {collection["name"]}')
return response.raw('', HTTPStatus.NO_CONTENT)
except UnknownCollection as e:
return json_response({'error': str(e)}, status=HTTPStatus.NOT_FOUND)
@collection_bp.post('/search')
@openapi.definition(
summary='Search collections',
body=schema.CollectionSearchRequest,
validate=True,
)
@openapi.response(HTTPStatus.OK, description="Search results")
async def search_collections_endpoint(_: Request, body: schema.CollectionSearchRequest):
"""
Search collections by kind, tags, and name.
Supports filtering by:
- kind: Collection type (domain, channel, etc.)
- tag_names: List of tag names (returns collections with any of these tags)
- search_str: Search string for collection names (case-insensitive partial match)
All filters are optional and can be combined.
"""
collections = lib.search_collections(
kind=body.kind,
tag_names=body.tag_names if body.tag_names else None,
search_str=body.search_str
)
return json_response({
'collections': collections,
'totals': {'collections': len(collections)}
})

View file

@ -0,0 +1,163 @@
import pathlib
from dataclasses import dataclass, field
from typing import List
from wrolpi.common import ConfigFile, logger
from wrolpi.db import get_db_session
from wrolpi.events import Events
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
logger = logger.getChild(__name__)
__all__ = ['CollectionsConfig', 'collections_config', 'save_collections_config']
@dataclass
class CollectionsConfigValidator:
"""Validator for collections config file."""
version: int = 0
collections: List[dict] = field(default_factory=list)
class CollectionsConfig(ConfigFile):
"""
Config file for Collections.
This config stores Collection metadata (name, description, directory, tag)
but NOT the individual items. Items are stored in the database.
For directory-restricted collections, items are auto-populated from the directory.
For unrestricted collections, items are managed manually through the API.
Format:
collections:
- name: "My Videos"
description: "Favorite videos"
directory: "videos/favorites" # optional, enables auto-population
tag_name: "favorites" # optional
- name: "Reading List"
description: "Books to read"
# No directory means manual item management
"""
file_name = 'collections.yaml'
validator = CollectionsConfigValidator
default_config = dict(
version=0,
collections=[],
)
# Use wider width to accommodate longer paths
width = 120
def __getitem__(self, item):
return self._config[item]
def __setitem__(self, key, value):
self._config[key] = value
@property
def collections(self) -> List[dict]:
"""Get list of collection configs."""
return self._config.get('collections', [])
def import_config(self, file: pathlib.Path = None, send_events=False):
"""Import collections from config file into database."""
from .models import Collection
super().import_config(file, send_events)
file_str = str(self.get_relative_file())
collections_data = self._config.get('collections', [])
if not collections_data:
logger.info(f'No collections to import from {file_str}')
self.successful_import = True
return
logger.info(f'Importing {len(collections_data)} collections from {file_str}')
try:
with get_db_session(commit=True) as session:
# Track which collections were imported
imported_dirs = set() # absolute paths for directory-restricted collections
imported_pairs = set() # (name, kind) for unrestricted collections
# Import each collection using from_config
for idx, collection_data in enumerate(collections_data):
try:
name = collection_data.get('name')
if not name:
logger.error(f'Collection at index {idx} has no name, skipping')
continue
# Use Collection.from_config to create/update
# This will auto-populate items if directory exists
collection = Collection.from_config(collection_data, session)
if collection.directory:
# Normalize to absolute string for comparison
imported_dirs.add(str(collection.directory))
else:
imported_pairs.add((collection.name, collection.kind))
except Exception as e:
logger.error(f'Failed to import collection at index {idx}', exc_info=e)
continue
# Delete collections that are no longer in config
all_collections = session.query(Collection).all()
for collection in all_collections:
if collection.directory:
if str(collection.directory) not in imported_dirs:
logger.info(
f'Deleting collection {repr(collection.name)} at {collection.directory} (no longer in config)')
session.delete(collection)
else:
if (collection.name, collection.kind) not in imported_pairs:
logger.info(
f"Deleting unrestricted collection {repr(collection.name)} kind={collection.kind} (no longer in config)")
session.delete(collection)
total_imported = len(imported_dirs) + len(imported_pairs)
logger.info(f'Successfully imported {total_imported} collections from {file_str}')
self.successful_import = True
except Exception as e:
self.successful_import = False
message = f'Failed to import {file_str} config!'
logger.error(message, exc_info=e)
if send_events:
Events.send_config_import_failed(message)
raise
def dump_config(self, file: pathlib.Path = None, send_events=False, overwrite=False):
"""Dump all collections from database to config file."""
from .models import Collection
logger.info('Dumping collections to config')
with get_db_session() as session:
# Order by name for consistency
collections = session.query(Collection).order_by(Collection.name).all()
# Use to_config to export each collection
collections_data = [collection.to_config() for collection in collections]
self._config['collections'] = collections_data
logger.info(f'Dumping {len(collections_data)} collections to config')
self.save(file, send_events, overwrite)
# Global instance
collections_config = CollectionsConfig()
# Switch handler for saving collections config
@register_switch_handler('save_collections_config')
def save_collections_config():
"""Save the collections config when the switch is activated."""
collections_config.background_dump.activate_switch()
# Explicit type for activate_switch helper
save_collections_config: ActivateSwitchMethod

View file

@ -0,0 +1,9 @@
from wrolpi.errors import APIError
__all__ = ['UnknownCollection']
class UnknownCollection(APIError):
"""Cannot find Collection"""
code = 'UNKNOWN_COLLECTION'
summary = 'Cannot find Collection'

652
wrolpi/collections/lib.py Normal file
View file

@ -0,0 +1,652 @@
"""
Collection Library Functions
Provides generic operations for collections of any kind (domains, channels, etc.).
These functions are used by both the unified collection API and legacy endpoints.
"""
import asyncio
from typing import List, Optional, Dict
from sqlalchemy import func
from sqlalchemy.orm import Session
from wrolpi.common import logger, get_media_directory, get_relative_to_media_directory
from wrolpi.db import get_db_session, optional_session
from wrolpi.errors import ValidationError
from wrolpi.events import Events
from wrolpi.tags import Tag
from .errors import UnknownCollection
from .models import Collection
logger = logger.getChild(__name__)
__all__ = [
'get_collections',
'get_collection_with_stats',
'get_collection_metadata',
'update_collection',
'refresh_collection',
'tag_collection',
'get_tag_info',
'delete_collection',
'search_collections',
]
@optional_session
def get_collections(kind: Optional[str] = None, session: Session = None) -> List[dict]:
"""
Get all collections, optionally filtered by kind.
Args:
kind: Optional collection kind to filter by (e.g., 'domain', 'channel')
session: Database session
Returns:
List of collection dicts with statistics for each collection type
"""
query = session.query(Collection)
if kind:
query = query.filter(Collection.kind == kind)
collections = query.order_by(Collection.name).all()
# Convert to JSON and add type-specific statistics
result = []
for collection in collections:
data = collection.__json__()
# Compute minimum download frequency from all downloads
if collection.downloads:
# Filter to recurring downloads (frequency > 0) and get minimum
recurring_frequencies = [d.frequency for d in collection.downloads if d.frequency and d.frequency > 0]
data['min_download_frequency'] = min(recurring_frequencies) if recurring_frequencies else None
else:
data['min_download_frequency'] = None
# Add type-specific statistics
if collection.kind == 'domain':
# Add archive statistics for domain collections
from modules.archive import Archive
from wrolpi.files.models import FileGroup
stats_query = session.query(
func.count(Archive.id).label('archive_count'),
func.sum(FileGroup.size).label('size')
).outerjoin(
FileGroup, FileGroup.id == Archive.file_group_id
).filter(
Archive.collection_id == collection.id
).one()
data['archive_count'] = stats_query.archive_count or 0
data['size'] = stats_query.size or 0
data['domain'] = data['name'] # Alias for backward compatibility
elif collection.kind == 'channel':
# Add video statistics for channel collections
from modules.videos.models import Video, Channel
from wrolpi.files.models import FileGroup
# Get the Channel associated with this collection
channel = session.query(Channel).filter(
Channel.collection_id == collection.id
).one_or_none()
if channel:
stats_query = session.query(
func.count(Video.id).label('video_count'),
func.sum(FileGroup.size).label('size')
).outerjoin(
FileGroup, FileGroup.id == Video.file_group_id
).filter(
Video.channel_id == channel.id
).one()
data['video_count'] = int(stats_query.video_count or 0)
data['total_size'] = int(stats_query.size or 0)
data['channel_id'] = channel.id # Include actual Channel ID for frontend links
else:
data['video_count'] = 0
data['total_size'] = 0
data['channel_id'] = None
result.append(data)
return result
@optional_session
def get_collection_with_stats(collection_id: int, session: Session = None) -> dict:
"""
Get a single collection with type-specific statistics.
Args:
collection_id: The collection ID
session: Database session
Returns:
Collection dict with statistics
Raises:
UnknownCollection: If collection not found
"""
collection = session.query(Collection).filter_by(id=collection_id).one_or_none()
if not collection:
raise UnknownCollection(f"Collection with ID {collection_id} not found")
# Get base collection data
data = collection.__json__()
# Add type-specific statistics
# This can be extended for different collection types
if collection.kind == 'domain':
# Add archive statistics for domain collections
from modules.archive import Archive
from wrolpi.files.models import FileGroup
stats_query = session.query(
func.count(Archive.id).label('archive_count'),
func.sum(FileGroup.size).label('size')
).outerjoin(
FileGroup, FileGroup.id == Archive.file_group_id
).filter(
Archive.collection_id == collection_id
).one()
data['archive_count'] = stats_query.archive_count or 0
data['size'] = stats_query.size or 0
data['domain'] = data['name'] # Alias for backward compatibility
elif collection.kind == 'channel':
# Add video statistics for channel collections
from modules.videos.models import Video, Channel
from wrolpi.files.models import FileGroup
# Get the Channel associated with this collection
channel = session.query(Channel).filter(
Channel.collection_id == collection_id
).one_or_none()
if channel:
stats_query = session.query(
func.count(Video.id).label('video_count'),
func.sum(FileGroup.size).label('size')
).outerjoin(
FileGroup, FileGroup.id == Video.file_group_id
).filter(
Video.channel_id == channel.id
).one()
data['video_count'] = int(stats_query.video_count or 0)
data['total_size'] = int(stats_query.size or 0)
else:
data['video_count'] = 0
data['total_size'] = 0
return data
def get_collection_metadata(kind: str) -> dict:
"""
Get metadata for a collection kind.
Metadata includes:
- Column definitions for table display
- Field configurations for edit forms
- Route information for navigation
- User-facing messages
Args:
kind: Collection kind ('domain', 'channel', etc.)
Returns:
Metadata dict with columns, fields, routes, and messages
"""
if kind == 'domain':
return {
'kind': 'domain',
'columns': [
{'key': 'domain', 'label': 'Domain', 'sortable': True},
{'key': 'tag_name', 'label': 'Tag', 'sortable': True},
{'key': 'archive_count', 'label': 'Archives', 'sortable': True, 'align': 'right'},
{'key': 'min_download_frequency', 'label': 'Download Frequency', 'sortable': True, 'format': 'frequency'},
{'key': 'size', 'label': 'Size', 'sortable': True, 'align': 'right', 'format': 'bytes'},
{'key': 'actions', 'label': 'Manage', 'sortable': False, 'type': 'actions'}
],
'fields': [
{'key': 'directory', 'label': 'Directory', 'type': 'text',
'placeholder': 'Optional directory path'},
{'key': 'tag_name', 'label': 'Tag', 'type': 'tag',
'placeholder': 'Select or create tag', 'depends_on': 'directory'},
{'key': 'description', 'label': 'Description', 'type': 'textarea',
'placeholder': 'Optional description'}
],
'routes': {
'list': '/archive/domains',
'edit': '/archive/domain/:id/edit',
'search': '/archive',
'searchParam': 'domain'
},
'messages': {
'no_directory': 'Set a directory to enable tagging',
'tag_will_move': 'Tagging will move files to a new directory'
}
}
elif kind == 'channel':
return {
'kind': 'channel',
'columns': [
{'key': 'name', 'label': 'Name', 'sortable': True},
{'key': 'tag_name', 'label': 'Tag', 'sortable': True},
{'key': 'video_count', 'label': 'Videos', 'sortable': True, 'align': 'right'},
{'key': 'min_download_frequency', 'label': 'Download Frequency', 'sortable': True, 'format': 'frequency'},
{'key': 'total_size', 'label': 'Size', 'sortable': True, 'align': 'right', 'format': 'bytes'},
{'key': 'actions', 'label': 'Manage', 'sortable': False, 'type': 'actions'}
],
'fields': [
{'key': 'name', 'label': 'Name', 'type': 'text', 'required': True},
{'key': 'url', 'label': 'URL', 'type': 'text', 'placeholder': 'Channel URL'},
{'key': 'directory', 'label': 'Directory', 'type': 'text', 'required': True},
{'key': 'tag_name', 'label': 'Tag', 'type': 'tag',
'placeholder': 'Select or create tag'},
{'key': 'description', 'label': 'Description', 'type': 'textarea',
'placeholder': 'Optional description'}
],
'routes': {
'list': '/videos/channels',
'edit': '/videos/channel/:id/edit',
'search': '/videos/channel/:id/video',
'id_field': 'channel_id' # Use channel_id instead of collection id for URLs
},
'messages': {
'no_directory': 'Directory is required for channels',
'tag_will_move': 'Tagging will move files to a new directory'
}
}
else:
# Generic metadata for unknown kinds
return {
'kind': kind or 'collection',
'columns': [
{'key': 'name', 'label': 'Name', 'sortable': True},
{'key': 'item_count', 'label': 'Items', 'sortable': True, 'align': 'right'},
{'key': 'total_size', 'label': 'Size', 'sortable': True, 'align': 'right', 'format': 'bytes'},
{'key': 'tag_name', 'label': 'Tag', 'sortable': True},
{'key': 'actions', 'label': 'Manage', 'sortable': False, 'type': 'actions'}
],
'fields': [
{'key': 'name', 'label': 'Name', 'type': 'text', 'required': True},
{'key': 'directory', 'label': 'Directory', 'type': 'text'},
{'key': 'tag_name', 'label': 'Tag', 'type': 'tag'},
{'key': 'description', 'label': 'Description', 'type': 'textarea'}
],
'routes': {
'list': '/collections',
'edit': '/collection/:id/edit',
'search': '/search'
},
'messages': {
'no_directory': 'Set a directory to enable tagging',
'tag_will_move': 'Tagging will move files to a new directory'
}
}
@optional_session
def update_collection(
collection_id: int,
directory: Optional[str] = None,
tag_name: Optional[str] = None,
description: Optional[str] = None,
session: Session = None
) -> Collection:
"""
Update a collection's properties.
Args:
collection_id: The collection ID
directory: New directory (relative or absolute path), or None to clear
tag_name: New tag name, empty string to clear, or None to leave unchanged
description: New description, or None to leave unchanged
session: Database session
Returns:
Updated Collection object
Raises:
UnknownCollection: If collection not found
ValidationError: If validation fails
"""
collection = session.query(Collection).filter_by(id=collection_id).one_or_none()
if not collection:
raise UnknownCollection(f"Collection with ID {collection_id} not found")
# Update directory if provided
if directory is not None:
if directory:
# Convert relative path to absolute with validation
from .models import validate_collection_directory
directory_path = validate_collection_directory(directory)
collection.directory = directory_path
else:
collection.directory = None
# Update description if provided
if description is not None:
collection.description = description
# Update tag if provided
if tag_name is not None:
if tag_name:
# Set or create tag
if not collection.directory:
raise ValidationError(
f"Collection '{collection.name}' has no directory. "
f"Set a directory before tagging."
)
tag = session.query(Tag).filter_by(name=tag_name).one_or_none()
if not tag:
tag = Tag(name=tag_name)
session.add(tag)
session.flush()
collection.tag_id = tag.id
elif tag_name == '':
# Clear tag (empty string explicitly clears)
collection.tag_id = None
session.flush()
# Trigger domain config save if this is a domain collection
if collection.kind == 'domain':
from modules.archive.lib import save_domains_config
save_domains_config.activate_switch()
return collection
def refresh_collection(collection_id: int, send_events: bool = True) -> None:
"""
Refresh all files in a collection's directory.
Args:
collection_id: The collection ID
send_events: Whether to send events about the refresh
Raises:
UnknownCollection: If collection not found
ValidationError: If collection has no directory
"""
from wrolpi.files.lib import refresh_files
with get_db_session() as session:
collection = session.query(Collection).filter_by(id=collection_id).one_or_none()
if not collection:
raise UnknownCollection(f"Collection with ID {collection_id} not found")
if not collection.directory:
raise ValidationError(
f"Collection '{collection.name}' has no directory. "
f"Set a directory before refreshing."
)
directory = collection.directory
# Refresh files asynchronously
asyncio.ensure_future(refresh_files([directory], send_events=send_events))
if send_events:
relative_dir = get_relative_to_media_directory(directory)
Events.send_directory_refresh(f'Refreshing: {relative_dir}')
@optional_session
def tag_collection(
collection_id: int,
tag_name: Optional[str] = None,
directory: Optional[str] = None,
session: Session = None
) -> Dict:
"""
Tag a collection and optionally move files to a new directory, or remove a tag if no tag_name is provided.
Args:
collection_id: The collection ID
tag_name: Tag name to apply, or None to remove the tag
directory: Optional new directory for the collection
session: Database session
Returns:
Dict with tag information and suggested directory
Raises:
UnknownCollection: If collection not found
ValidationError: If tagging requirements not met
"""
collection = session.query(Collection).filter_by(id=collection_id).one_or_none()
if not collection:
raise UnknownCollection(f"Collection with ID {collection_id} not found")
# If tag_name is None, remove the tag from the collection
if tag_name is None:
collection.tag_id = None
session.flush()
# Trigger domain config save if this is a domain collection
if collection.kind == 'domain':
from modules.archive.lib import save_domains_config
save_domains_config.activate_switch()
# Return info about the un-tagging operation
relative_dir = get_relative_to_media_directory(collection.directory) if collection.directory else None
return {
'collection_id': collection.id,
'collection_name': collection.name,
'tag_name': None,
'directory': str(relative_dir) if relative_dir else None,
'will_move_files': False,
}
# Get or create the tag
tag = session.query(Tag).filter_by(name=tag_name).one_or_none()
if not tag:
tag = Tag(name=tag_name)
session.add(tag)
session.flush()
# Determine target directory
if directory:
# User specified a directory - validate it
from .models import validate_collection_directory
target_directory = validate_collection_directory(directory)
elif collection.directory:
# Use existing directory
target_directory = collection.directory
else:
# Suggest a directory based on collection type and name
if collection.kind == 'domain':
from modules.archive.lib import get_archive_directory
base_dir = get_archive_directory()
else:
base_dir = get_media_directory()
target_directory = base_dir / collection.name
# Apply the tag
collection.tag_id = tag.id
collection.directory = target_directory
session.flush()
# Trigger domain config save if this is a domain collection
if collection.kind == 'domain':
from modules.archive.lib import save_domains_config
save_domains_config.activate_switch()
# Return info about the tagging operation
relative_dir = get_relative_to_media_directory(target_directory)
return {
'collection_id': collection.id,
'collection_name': collection.name,
'tag_name': tag_name,
'directory': str(relative_dir),
'will_move_files': collection.directory != target_directory,
}
@optional_session
def get_tag_info(
collection_id: int,
tag_name: Optional[str],
session: Session = None
) -> Dict:
"""
Get information about tagging a collection with a specific tag.
Returns the suggested directory and checks for conflicts with existing collections.
Args:
collection_id: The collection ID
tag_name: Tag name to check
session: Database session
Returns:
Dict with suggested_directory, conflict flag, and optional conflict_message
Raises:
UnknownCollection: If collection not found
"""
collection = session.query(Collection).filter_by(id=collection_id).one_or_none()
if not collection:
raise UnknownCollection(f"Collection with ID {collection_id} not found")
# Use the collection's format_directory method to get the suggested directory
suggested_directory = collection.format_directory(tag_name)
# Check for directory conflicts with other domain collections
conflict = False
conflict_message = None
# Only check for conflicts if this is a domain collection
if collection.kind == 'domain':
# Check if another domain collection already has this directory
existing_domain = session.query(Collection).filter(
Collection.directory == str(suggested_directory),
Collection.kind == 'domain',
Collection.id != collection_id
).first()
if existing_domain:
conflict = True
conflict_message = (
f"A domain collection '{existing_domain.name}' already uses this directory. "
f"Choose a different tag or directory."
)
# Return relative path for the frontend
relative_dir = get_relative_to_media_directory(suggested_directory)
return {
'suggested_directory': str(relative_dir),
'conflict': conflict,
'conflict_message': conflict_message,
}
@optional_session
def delete_collection(
collection_id: int,
session: Session = None
) -> Dict:
"""
Delete a collection and orphan its child items.
For domain collections:
- Orphans child Archives (sets collection_id to NULL)
- Deletes the Collection record
- Triggers domain config save
Args:
collection_id: The collection ID to delete
session: Database session
Returns:
Dict with collection information
Raises:
UnknownCollection: If collection not found
"""
collection = session.query(Collection).filter_by(id=collection_id).one_or_none()
if not collection:
raise UnknownCollection(f"Collection with ID {collection_id} not found")
collection_dict = {
'id': collection.id,
'name': collection.name,
'kind': collection.kind,
}
# Orphan child Archives if this is a domain collection
if collection.kind == 'domain':
from modules.archive.models import Archive
archives = session.query(Archive).filter_by(collection_id=collection_id).all()
for archive in archives:
archive.collection_id = None
session.flush()
# Trigger domain config save
from modules.archive.lib import save_domains_config
save_domains_config.activate_switch()
# Delete the collection
session.delete(collection)
session.flush()
return collection_dict
@optional_session
def search_collections(
kind: Optional[str] = None,
tag_names: Optional[List[str]] = None,
search_str: Optional[str] = None,
session: Session = None
) -> List[dict]:
"""
Search collections by kind, tags, and search string.
Args:
kind: Optional collection kind filter
tag_names: Optional list of tag names to filter by
search_str: Optional search string for collection names
session: Database session
Returns:
List of matching collection dicts
"""
query = session.query(Collection)
# Filter by kind
if kind:
query = query.filter(Collection.kind == kind)
# Filter by tags
if tag_names:
query = query.join(Tag).filter(Tag.name.in_(tag_names))
# Filter by search string
if search_str:
query = query.filter(Collection.name.ilike(f'%{search_str}%'))
# Order by name
query = query.order_by(Collection.name)
collections = query.all()
return [collection.__json__() for collection in collections]

View file

@ -0,0 +1,814 @@
import pathlib
from typing import Optional, List
from sqlalchemy import Column, Integer, String, ForeignKey, Text, DateTime, Index, UniqueConstraint, func
from sqlalchemy.orm import relationship, Session
from sqlalchemy.orm.collections import InstrumentedList
from wrolpi.common import Base, ModelHelper, logger, get_media_directory
from wrolpi.db import optional_session
from wrolpi.files.models import FileGroup
from wrolpi.media_path import MediaPathType
from wrolpi.tags import Tag
from .errors import UnknownCollection
from .types import collection_type_registry
logger = logger.getChild(__name__)
__all__ = ['Collection', 'CollectionItem']
def validate_collection_directory(directory: pathlib.Path) -> pathlib.Path:
"""Validate and normalize a collection directory path.
Args:
directory: Directory path (relative or absolute)
Returns:
Normalized absolute path under media directory
Raises:
ValidationError: If absolute path is outside media directory
"""
from wrolpi.errors import ValidationError
media_directory = get_media_directory()
directory = pathlib.Path(directory)
if not directory.is_absolute():
# Relative path - make absolute under media directory
directory = media_directory / directory
else:
# Absolute path - must be under media directory
try:
directory.relative_to(media_directory)
except ValueError:
raise ValidationError(
f"Collection directory must be under media directory {media_directory}, "
f"but got {directory}"
)
return directory
class Collection(ModelHelper, Base):
"""
A Collection is a grouping of FileGroups (videos, archives, ebooks, etc).
Collections can be:
- Directory-restricted: Only contains files within a specific directory tree
- Unrestricted: Contains files from anywhere in the media library
Collections maintain order through the CollectionItem junction table.
"""
__tablename__ = 'collection'
__table_args__ = (
UniqueConstraint('directory', name='uq_collection_directory'),
UniqueConstraint('name', 'kind', name='uq_collection_name_kind'),
Index('idx_collection_kind', 'kind'),
)
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
description = Column(Text)
# Type of collection to keep separation of kinds (e.g., videos, archives, books, etc.)
kind = Column(String, nullable=False, server_default='channel', default='channel')
# Optional directory restriction - if set, only files in this directory tree can be added
directory: Optional[pathlib.Path] = Column(MediaPathType, nullable=True)
# Optional tag relationship (similar to Channel)
tag_id = Column(Integer, ForeignKey('tag.id', ondelete='CASCADE'))
tag = relationship('Tag', primaryjoin='Collection.tag_id==Tag.id')
created_date = Column(DateTime, server_default=func.now())
# Columns updated by triggers (similar to Channel)
item_count = Column(Integer, default=0)
total_size = Column(Integer, default=0)
# Relationship to items (ordered)
items: InstrumentedList = relationship(
'CollectionItem',
primaryjoin='Collection.id==CollectionItem.collection_id',
back_populates='collection',
order_by='CollectionItem.position',
cascade='all, delete-orphan'
)
# Relationship to downloads
downloads: InstrumentedList = relationship(
'Download',
primaryjoin='Download.collection_id==Collection.id',
back_populates='collection'
)
def __repr__(self):
return f'<Collection id={self.id} name={repr(self.name)} directory={self.directory} items={self.item_count}>'
@property
def tag_name(self) -> str | None:
return self.tag.name if self.tag else None
@property
def location(self) -> str:
"""The href of the collection in the App."""
return f'/collections/{self.id}'
@property
def is_directory_restricted(self) -> bool:
"""Returns True if this collection is restricted to a specific directory."""
return self.directory is not None
@staticmethod
def is_valid_domain_name(name: str) -> bool:
"""
Validate that a name is a valid domain format.
A valid domain must:
- Be a string
- Contain at least one "." (e.g., "example.com")
- Not start or end with "."
Examples:
Valid: "example.com", "sub.example.com", "a.b.c"
Invalid: "example", "example.", ".example", "."
Args:
name: The domain name to validate
Returns:
True if valid domain format, False otherwise
"""
# Use the registry for validation
return collection_type_registry.validate('domain', name)
def to_config(self) -> dict:
"""
Export this Collection's metadata to a config dict.
Only includes the minimum data necessary for reconstruction.
"""
config = {
'name': self.name,
'kind': self.kind,
}
if self.description:
config['description'] = self.description
if self.directory:
# Store absolute path for consistency with Channel config
config['directory'] = str(self.directory)
if self.tag_name:
config['tag_name'] = self.tag_name
# Include downloads if any exist
if self.downloads:
config['downloads'] = [
{'url': d.url, 'frequency': d.frequency}
for d in self.downloads
]
return config
@optional_session
def get_or_create_download(self, url: str, frequency: int, session: Session = None,
reset_attempts: bool = False,
downloader_name: str = None,
sub_downloader_name: str = None) -> 'Download':
"""Get or create a Download for this Collection.
Args:
url: The URL to download
frequency: Seconds between re-downloading
session: Database session
reset_attempts: Reset download attempts if True
downloader_name: Name of the downloader to use
sub_downloader_name: Name of the sub-downloader for child downloads
Returns:
The Download instance
Raises:
InvalidDownload: If url or frequency is missing
"""
from wrolpi.downloader import Download, download_manager
from wrolpi.errors import InvalidDownload
if not url:
raise InvalidDownload('Cannot get Download without url')
if not frequency:
raise InvalidDownload('Download for Collection must have a frequency')
download = Download.get_by_url(url, session=session)
if not download:
download = download_manager.recurring_download(
url, frequency, downloader_name, session=session,
sub_downloader_name=sub_downloader_name,
destination=str(self.directory) if self.directory else None,
reset_attempts=reset_attempts,
)
if reset_attempts:
download.attempts = 0
download.collection_id = self.id
return download
@staticmethod
@optional_session
def from_config(data: dict, session: Session = None) -> 'Collection':
"""
Create or update a Collection from config data.
If the collection has a directory, auto-populate with FileGroups from that directory.
If the collection has no directory, items are managed manually (not from config).
Args:
data: Config dict containing collection metadata
session: Database session
Returns:
The created or updated Collection
Raises:
ValueError: If required fields are missing
"""
name = data.get('name')
if not name:
raise ValueError('Collection config must have a name')
description = data.get('description')
directory = data.get('directory')
tag_name = data.get('tag_name')
kind = (data.get('kind') or 'channel').strip()
# Validate known kinds (forward-compatible: allow future values)
if kind not in {'channel', 'domain'}:
logger.warning(f"Unknown collection kind '{kind}', defaulting to 'channel'")
kind = 'channel'
# Validate collection name using the type registry
if not collection_type_registry.validate(kind, name):
description = collection_type_registry.get_description(kind) or "Invalid name format"
raise ValueError(f'Invalid {kind} name for collection: {repr(name)}. {description}')
# Convert directory to absolute path if provided, with validation
if directory:
directory = validate_collection_directory(directory)
# Try to find existing collection:
# - If directory is provided, resolve by unique directory
# - Otherwise, fall back to first match by (name, kind)
if directory:
collection = Collection.get_by_path(directory, session)
else:
collection = session.query(Collection).filter_by(name=name, kind=kind).first()
if collection:
# Update existing collection
logger.debug(f'Updating collection from config: {name}')
collection.description = description
collection.directory = directory
collection.kind = kind
if tag_name:
tag = Tag.get_by_name(tag_name, session)
if tag:
collection.tag = tag
else:
logger.warning(f'Tag {repr(tag_name)} not found for collection {repr(name)}')
collection.tag = None
else:
collection.tag = None
else:
# Create new collection
logger.info(f'Creating new collection from config: {name}')
collection = Collection(
name=name,
description=description,
directory=directory,
kind=kind,
)
if tag_name:
tag = Tag.get_by_name(tag_name, session)
if tag:
collection.tag = tag
else:
logger.warning(f'Tag {repr(tag_name)} not found for collection {repr(name)}')
session.add(collection)
session.flush([collection])
# If directory-restricted, populate with FileGroups from that directory
if collection.directory and collection.directory.is_dir():
logger.info(f'Populating collection {repr(name)} from directory {collection.directory}')
collection.populate_from_directory(session=session)
elif collection.directory and not collection.directory.is_dir():
logger.warning(f'Collection directory does not exist: {collection.directory}')
return collection
def populate_from_directory(self, session: Session = None):
"""
Populate this collection with all FileGroups in the collection's directory.
Only works for directory-restricted collections.
"""
session = session or Session.object_session(self)
if not self.is_directory_restricted:
logger.warning(f'Cannot populate unrestricted collection from directory: {self}')
return
if not self.directory.is_dir():
logger.warning(f'Cannot populate collection, directory does not exist: {self.directory}')
return
# Find all FileGroups in this directory tree
file_groups = session.query(FileGroup).filter(
FileGroup.primary_path.like(f'{self.directory}/%')
).all()
if not file_groups:
logger.debug(f'No FileGroups found in {self.directory}')
return
# Get existing items to avoid duplicates
existing_fg_ids = {item.file_group_id for item in self.items}
# Add new FileGroups
new_file_groups = [fg for fg in file_groups if fg.id not in existing_fg_ids]
if new_file_groups:
self.add_file_groups(new_file_groups, session=session)
logger.info(f'Added {len(new_file_groups)} FileGroups to collection {repr(self.name)}')
def validate_file_group(self, file_group: FileGroup) -> bool:
"""
Check if a FileGroup can be added to this Collection.
Returns True if the FileGroup is valid for this collection:
- If directory-restricted, the file must be in the directory tree
- Otherwise, any file is valid
"""
if not self.is_directory_restricted:
return True
# Check if the file_group's primary_path is within the collection's directory
try:
file_path = file_group.primary_path
if file_path:
# Check if file is in directory tree
file_path.relative_to(self.directory)
return True
except (ValueError, AttributeError):
# relative_to raises ValueError if path is not relative
return False
return False
def add_file_group(self, file_group: FileGroup, position: Optional[int] = None,
session: Session = None) -> 'CollectionItem':
"""
Add a FileGroup to this Collection.
Args:
file_group: The FileGroup to add
position: Optional position in the collection (None = append to end)
session: Database session
Returns:
The created CollectionItem
Raises:
ValueError: If the file_group cannot be added to this collection
"""
session = session or Session.object_session(self)
if not self.validate_file_group(file_group):
raise ValueError(
f'FileGroup {file_group.id} at {file_group.primary_path} '
f'cannot be added to collection "{self.name}" (directory restriction: {self.directory})'
)
# Check if already exists
existing = session.query(CollectionItem).filter_by(
collection_id=self.id,
file_group_id=file_group.id
).first()
if existing:
logger.warning(f'FileGroup {file_group.id} already in Collection {self.id}')
return existing
# Determine position
if position is None:
# Append to end
max_position = session.query(func.max(CollectionItem.position)).filter_by(
collection_id=self.id
).scalar()
position = (max_position or 0) + 1
else:
# Insert at specific position - need to shift existing items
self._shift_positions(position, shift_by=1, session=session)
item = CollectionItem(
collection_id=self.id,
file_group_id=file_group.id,
position=position
)
session.add(item)
session.flush([item])
return item
def add_file_groups(self, file_groups: List[FileGroup], session: Session = None) -> List['CollectionItem']:
"""
Add multiple FileGroups to this Collection in batch.
More efficient than calling add_file_group in a loop.
Args:
file_groups: List of FileGroups to add
session: Database session
Returns:
List of created CollectionItems
"""
session = session or Session.object_session(self)
# Validate all file groups first
for fg in file_groups:
if not self.validate_file_group(fg):
raise ValueError(
f'FileGroup {fg.id} at {fg.primary_path} '
f'cannot be added to collection "{self.name}" (directory restriction: {self.directory})'
)
# Get existing items to avoid duplicates
existing_fg_ids = {item.file_group_id for item in self.items}
new_file_groups = [fg for fg in file_groups if fg.id not in existing_fg_ids]
if not new_file_groups:
logger.debug(f'All FileGroups already in Collection {self.id}')
return []
# Get starting position
max_position = session.query(func.max(CollectionItem.position)).filter_by(
collection_id=self.id
).scalar()
position = (max_position or 0) + 1
# Create items in batch
items = []
for fg in new_file_groups:
item = CollectionItem(
collection_id=self.id,
file_group_id=fg.id,
position=position
)
items.append(item)
position += 1
session.add_all(items)
session.flush(items)
logger.debug(f'Added {len(items)} FileGroups to Collection {self.id}')
return items
def remove_file_group(self, file_group_id: int, session: Session = None):
"""Remove a FileGroup from this Collection."""
session = session or Session.object_session(self)
item = session.query(CollectionItem).filter_by(
collection_id=self.id,
file_group_id=file_group_id
).first()
if item:
position = item.position
session.delete(item)
session.flush()
# Shift remaining items down
self._shift_positions(position + 1, shift_by=-1, session=session)
def remove_file_groups(self, file_group_ids: List[int], session: Session = None):
"""
Remove multiple FileGroups from this Collection in batch.
More efficient than calling remove_file_group in a loop.
Args:
file_group_ids: List of FileGroup IDs to remove
session: Database session
"""
session = session or Session.object_session(self)
if not file_group_ids:
return
# Delete all items at once
deleted = session.query(CollectionItem).filter(
CollectionItem.collection_id == self.id,
CollectionItem.file_group_id.in_(file_group_ids)
).delete(synchronize_session=False)
logger.debug(f'Removed {deleted} FileGroups from Collection {self.id}')
# Resequence positions to close gaps (optional, but keeps positions clean)
items = session.query(CollectionItem).filter_by(
collection_id=self.id
).order_by(CollectionItem.position).all()
for idx, item in enumerate(items, start=1):
item.position = idx
session.flush(items)
def reorder_item(self, file_group_id: int, new_position: int, session: Session = None):
"""Move an item to a new position in the collection."""
session = session or Session.object_session(self)
item = session.query(CollectionItem).filter_by(
collection_id=self.id,
file_group_id=file_group_id
).first()
if not item:
raise ValueError(f'FileGroup {file_group_id} not in Collection {self.id}')
old_position = item.position
if old_position == new_position:
return
# Remove item from old position
session.query(CollectionItem).filter(
CollectionItem.collection_id == self.id,
CollectionItem.position > old_position
).update({'position': CollectionItem.position - 1})
# Make space at new position
session.query(CollectionItem).filter(
CollectionItem.collection_id == self.id,
CollectionItem.position >= new_position
).update({'position': CollectionItem.position + 1})
# Update item position
item.position = new_position
session.flush()
def _shift_positions(self, from_position: int, shift_by: int, session: Session):
"""Helper to shift positions of items."""
session.query(CollectionItem).filter(
CollectionItem.collection_id == self.id,
CollectionItem.position >= from_position
).update({'position': CollectionItem.position + shift_by})
session.flush()
def get_items(self, limit: Optional[int] = None, offset: int = 0,
session: Session = None) -> List['CollectionItem']:
"""Get items in this collection, ordered by position."""
session = session or Session.object_session(self)
query = session.query(CollectionItem).filter_by(
collection_id=self.id
).order_by(CollectionItem.position)
if offset:
query = query.offset(offset)
if limit:
query = query.limit(limit)
return query.all()
@staticmethod
@optional_session
def get_by_id(id_: int, session: Session = None) -> Optional['Collection']:
"""Attempt to find a Collection with the provided id. Returns None if not found."""
return session.query(Collection).filter_by(id=id_).one_or_none()
@staticmethod
@optional_session
def find_by_id(id_: int, session: Session = None) -> 'Collection':
"""Find a Collection with the provided id, raises an exception if not found.
@raise UnknownCollection: if the collection cannot be found
"""
if collection := Collection.get_by_id(id_, session=session):
return collection
raise UnknownCollection(f'Cannot find Collection with id {id_}')
@staticmethod
@optional_session
def get_by_path(path: pathlib.Path, session: Session = None) -> Optional['Collection']:
"""Find a Collection by its directory path. Returns None if not found."""
if not path:
return None
path = pathlib.Path(path) if isinstance(path, str) else path
path = str(path.absolute()) if path.is_absolute() else str(get_media_directory() / path)
return session.query(Collection).filter_by(directory=path).one_or_none()
@staticmethod
@optional_session
def find_by_path(path: pathlib.Path, session: Session = None) -> 'Collection':
"""Find a Collection by its directory path, raises an exception if not found.
@raise UnknownCollection: if the collection cannot be found
"""
if collection := Collection.get_by_path(path, session=session):
return collection
raise UnknownCollection(f'Cannot find Collection with directory {path}')
def dict(self) -> dict:
"""Return dictionary representation."""
d = super(Collection, self).dict()
d['tag_name'] = self.tag_name
# Directory may be outside media root; mirror to_config behavior
if self.directory:
try:
d['directory'] = self.directory.relative_to(get_media_directory())
except ValueError:
d['directory'] = self.directory
else:
d['directory'] = None
d['is_directory_restricted'] = self.is_directory_restricted
d['item_count'] = self.item_count
d['total_size'] = self.total_size
d['kind'] = self.kind
return d
# --- Tagging and moving helpers ---
def set_tag(self, tag_id_or_name: int | str | None) -> Optional[Tag]:
"""Assign or clear the Tag on this Collection. Mirrors Channel.set_tag semantics.
Raises:
ValueError: If attempting to tag a domain collection without a directory.
"""
# Validate domain collections can only be tagged if they have a directory
if self.kind == 'domain' and self.directory is None and tag_id_or_name is not None:
raise ValueError(
f"Cannot tag domain collection '{self.name}' without a directory. "
"Set collection.directory first or use an unrestricted domain collection."
)
session = Session.object_session(self)
if tag_id_or_name is None:
self.tag = None
elif isinstance(tag_id_or_name, int):
self.tag = Tag.find_by_id(tag_id_or_name, session)
elif isinstance(tag_id_or_name, str):
self.tag = Tag.find_by_name(tag_id_or_name, session)
self.tag_id = self.tag.id if self.tag else None
return self.tag
@property
def can_be_tagged(self) -> bool:
"""Check if this Collection can be tagged.
Domain collections require a directory to be tagged.
Other collection types can always be tagged.
Returns:
True if this collection can be tagged, False otherwise.
"""
if self.kind == 'domain':
return self.directory is not None
return True
def __json__(self) -> dict:
"""Return JSON-serializable dict for API responses.
Returns directory as a string relative to media directory if applicable.
"""
from wrolpi.common import get_relative_to_media_directory
# Get directory as string, relative to media directory
directory_str = None
if self.directory:
try:
directory_str = str(get_relative_to_media_directory(self.directory))
except ValueError:
# Directory is not relative to media directory, return as-is
directory_str = str(self.directory)
return {
'id': self.id,
'name': self.name,
'kind': self.kind,
'directory': directory_str,
'description': self.description,
'tag_name': self.tag.name if self.tag else None,
'can_be_tagged': self.can_be_tagged,
'item_count': self.item_count,
'total_size': self.total_size,
'downloads': self.downloads,
}
def format_directory(self, tag_name: Optional[str]) -> pathlib.Path:
"""Compute the destination directory for this Collection given a tag name.
For video-style collections (kind == 'channel'), reuse videos destination formatting:
videos/<Tag>/<Collection Name>
For domain collections (kind == 'domain'), use the configured archive directory:
archive/<Tag>/<Domain Name>
"""
if self.kind == 'channel':
from modules.videos.lib import format_videos_destination
return format_videos_destination(self.name, tag_name, None)
elif self.kind == 'domain':
# Use configured archive directory for domain collections
from modules.archive.lib import get_archive_directory
base = get_archive_directory()
if tag_name:
return base / tag_name / self.name
return base / self.name
# Default behavior: place under media root by kind/tag/name
base = get_media_directory() / self.kind
if tag_name:
return base / tag_name / self.name
return base / self.name
async def move_collection(self, directory: pathlib.Path, session: Session, send_events: bool = False):
"""Move all files under this Collection's directory to a new directory.
Similar to Channel.move_channel but without download management.
"""
from wrolpi.files.lib import move as move_files, refresh_files
from wrolpi.events import Events
from wrolpi import flags
if not directory.is_dir():
raise FileNotFoundError(f'Destination directory does not exist: {directory}')
if not self.directory:
raise RuntimeError('Cannot move an unrestricted Collection (no directory set)')
old_directory = self.directory
self.directory = directory
with flags.refreshing:
session.commit()
# Move the contents of the Collection directory into the destination directory.
logger.info(f'Moving Collection {repr(self.name)} from {repr(str(old_directory))}')
try:
if not old_directory.exists():
# Old directory does not exist; refresh both
await refresh_files([old_directory, directory])
if send_events:
Events.send_file_move_completed(f'Collection {repr(self.name)} was moved (directory missing)')
else:
await move_files(directory, *list(old_directory.iterdir()), session=session)
if send_events:
Events.send_file_move_completed(f'Collection {repr(self.name)} was moved')
except Exception as e:
logger.error(f'Collection move failed! Reverting changes...', exc_info=e)
self.directory = old_directory
self.flush(session)
if send_events:
Events.send_file_move_failed(f'Moving Collection {self.name} has failed')
raise
finally:
session.commit()
if old_directory.exists() and not next(iter(old_directory.iterdir()), None):
old_directory.rmdir()
class CollectionItem(ModelHelper, Base):
"""
Junction table between Collection and FileGroup.
Maintains ordering and metadata about the relationship.
"""
__tablename__ = 'collection_item'
id = Column(Integer, primary_key=True)
collection_id = Column(Integer, ForeignKey('collection.id', ondelete='CASCADE'), nullable=False)
file_group_id = Column(Integer, ForeignKey('file_group.id', ondelete='CASCADE'), nullable=False)
# Position in the collection (for ordering)
position = Column(Integer, nullable=False, default=0)
# When this item was added
added_date = Column(DateTime, server_default=func.now())
# Relationships
collection = relationship('Collection', back_populates='items')
file_group: FileGroup = relationship('FileGroup')
# Indexes for performance
__table_args__ = (
Index('idx_collection_item_collection_position', 'collection_id', 'position'),
Index('idx_collection_item_file_group', 'file_group_id'),
UniqueConstraint('collection_id', 'file_group_id', name='uq_collection_file_group'),
)
def __repr__(self):
return f'<CollectionItem collection={self.collection_id} file_group={self.file_group_id} position={self.position}>'
def dict(self) -> dict:
"""Return dictionary representation."""
d = super(CollectionItem, self).dict()
if self.file_group:
d['file_group'] = self.file_group.__json__()
return d

View file

@ -0,0 +1,54 @@
"""
Collection API Schemas
Request and response schemas for the unified collection API endpoints.
"""
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class CollectionUpdateRequest:
"""Request body for updating a collection."""
directory: Optional[str] = None
tag_name: Optional[str] = None
description: Optional[str] = None
@dataclass
class CollectionTagRequest:
"""Request body for tagging a collection."""
tag_name: Optional[str] = None
directory: Optional[str] = None
@dataclass
class CollectionSearchRequest:
"""Request body for searching collections."""
kind: Optional[str] = None
tag_names: List[str] = field(default_factory=list)
search_str: Optional[str] = None
@dataclass
class CollectionTagResponse:
"""Response for tagging operation."""
collection_id: int
collection_name: str
tag_name: str
directory: str
will_move_files: bool
@dataclass
class CollectionTagInfoRequest:
"""Request body for getting tag info."""
tag_name: Optional[str] = None
@dataclass
class CollectionTagInfoResponse:
"""Response for tag info operation."""
suggested_directory: str
conflict: bool
conflict_message: Optional[str] = None

View file

@ -0,0 +1,485 @@
"""Tests for Collection API endpoints."""
from http import HTTPStatus
import pytest
from wrolpi.collections.models import Collection
class TestCollectionsAPI:
"""Test the GET /api/collections endpoint."""
@pytest.mark.asyncio
async def test_get_collections_returns_metadata_for_domains(
self, async_client, test_session
):
"""Test that GET /api/collections?kind=domain returns metadata."""
# Create a test domain collection
collection = Collection(name='example.com', kind='domain')
test_session.add(collection)
test_session.commit()
# Make the request
request, response = await async_client.get('/api/collections?kind=domain')
# Check response
assert response.status_code == HTTPStatus.OK
data = response.json
# Check basic response structure
assert 'collections' in data
assert 'totals' in data
assert 'metadata' in data
# Check collections
assert len(data['collections']) == 1
assert data['collections'][0]['name'] == 'example.com'
assert data['collections'][0]['kind'] == 'domain'
# Check totals
assert data['totals']['collections'] == 1
# Check metadata structure
metadata = data['metadata']
assert metadata['kind'] == 'domain'
assert 'columns' in metadata
assert 'fields' in metadata
assert 'routes' in metadata
assert 'messages' in metadata
# Check metadata columns
columns = metadata['columns']
assert len(columns) > 0
column_keys = [col['key'] for col in columns]
assert 'domain' in column_keys
assert 'archive_count' in column_keys
assert 'size' in column_keys
assert 'tag_name' in column_keys
assert 'actions' in column_keys
# Check metadata fields
fields = metadata['fields']
assert len(fields) > 0
field_keys = [field['key'] for field in fields]
assert 'directory' in field_keys
assert 'tag_name' in field_keys
assert 'description' in field_keys
# Check metadata routes
routes = metadata['routes']
assert routes['list'] == '/archive/domains'
assert routes['edit'] == '/archive/domain/:id/edit'
assert routes['search'] == '/archive'
# Check metadata messages
messages = metadata['messages']
assert 'no_directory' in messages
assert 'tag_will_move' in messages
@pytest.mark.asyncio
async def test_get_collections_returns_metadata_for_channels(
self, async_client, test_session
):
"""Test that GET /api/collections?kind=channel returns channel metadata."""
from pathlib import Path
# Create a test channel collection
collection = Collection(
name='Test Channel',
kind='channel',
directory=Path('/media/wrolpi/videos/test')
)
test_session.add(collection)
test_session.commit()
# Make the request
request, response = await async_client.get('/api/collections?kind=channel')
# Check response
assert response.status_code == HTTPStatus.OK
data = response.json
# Check metadata structure
assert 'metadata' in data
metadata = data['metadata']
assert metadata['kind'] == 'channel'
# Check channel-specific metadata
column_keys = [col['key'] for col in metadata['columns']]
assert 'name' in column_keys
assert 'video_count' in column_keys
assert 'total_size' in column_keys
# Check channel routes
routes = metadata['routes']
assert routes['list'] == '/videos/channels'
assert routes['edit'] == '/videos/channel/:id/edit'
@pytest.mark.asyncio
async def test_get_collections_no_metadata_without_kind(
self, async_client, test_session
):
"""Test that GET /api/collections without kind parameter does not include metadata."""
# Create test collections of different kinds
domain = Collection(name='example.com', kind='domain')
channel = Collection(name='Test Channel', kind='channel')
test_session.add_all([domain, channel])
test_session.commit()
# Make the request without kind parameter
request, response = await async_client.get('/api/collections')
# Check response
assert response.status_code == HTTPStatus.OK
data = response.json
# Should have both collections
assert len(data['collections']) == 2
assert data['totals']['collections'] == 2
# Should NOT have metadata (since no specific kind was requested)
assert 'metadata' not in data
@pytest.mark.asyncio
async def test_get_empty_collections_with_metadata(
self, async_client, test_session
):
"""Test that metadata is returned even when no collections exist."""
# Don't create any collections
# Make the request
request, response = await async_client.get('/api/collections?kind=domain')
# Check response
assert response.status_code == HTTPStatus.OK
data = response.json
# Should have empty collections
assert len(data['collections']) == 0
assert data['totals']['collections'] == 0
# Should still have metadata
assert 'metadata' in data
assert data['metadata']['kind'] == 'domain'
@pytest.mark.asyncio
async def test_get_collections_includes_video_count_for_channels(
self, async_client, test_session, test_directory, channel_factory, video_factory
):
"""Test that GET /api/collections?kind=channel includes video statistics."""
# Create a channel using the factory
channel = channel_factory(name="Test Channel")
test_session.flush()
# Create a video in the channel
video = video_factory(channel_id=channel.id, with_video_file=True)
test_session.commit()
# Make the request
request, response = await async_client.get('/api/collections?kind=channel')
# Check response includes video statistics
assert response.status_code == HTTPStatus.OK
data = response.json
# Find our test channel
test_channel_data = next(c for c in data['collections'] if c['name'] == "Test Channel")
# Should have video_count and total_size
assert 'video_count' in test_channel_data
assert test_channel_data['video_count'] == 1
assert 'total_size' in test_channel_data
assert test_channel_data['total_size'] > 0
class TestCollectionTagInfoAPI:
"""Test the POST /api/collections/<id>/tag_info endpoint."""
@pytest.mark.asyncio
async def test_get_tag_info_suggests_directory(
self, async_client, test_session, test_directory
):
"""Test that tag_info endpoint suggests a directory for a domain collection."""
# Create a domain collection
collection = Collection(
name='example.com',
kind='domain',
directory=test_directory / 'archive' / 'example.com'
)
test_session.add(collection)
test_session.commit()
# Request tag info
request, response = await async_client.post(
f'/api/collections/{collection.id}/tag_info',
json={'tag_name': 'WROL'}
)
# Check response
assert response.status_code == HTTPStatus.OK
data = response.json
# Should suggest a directory with the tag
assert 'suggested_directory' in data
assert 'WROL' in data['suggested_directory']
assert 'example.com' in data['suggested_directory']
# Should not have a conflict
assert data['conflict'] is False
assert data['conflict_message'] is None
@pytest.mark.asyncio
async def test_get_tag_info_detects_domain_conflict(
self, async_client, test_session, test_directory
):
"""Test that tag_info detects conflicts with existing domain collections."""
# Create two domain collections
collection1 = Collection(
name='example.com',
kind='domain',
directory=test_directory / 'archive' / 'example.com'
)
collection2 = Collection(
name='other.com',
kind='domain',
directory=test_directory / 'archive' / 'WROL' / 'other.com'
)
test_session.add_all([collection1, collection2])
test_session.commit()
# Request tag info for collection1 with a tag that would conflict with collection2
request, response = await async_client.post(
f'/api/collections/{collection1.id}/tag_info',
json={'tag_name': 'WROL'}
)
# Check response
assert response.status_code == HTTPStatus.OK
data = response.json
# Should suggest archive/WROL/example.com
assert 'suggested_directory' in data
assert 'WROL' in data['suggested_directory']
assert 'example.com' in data['suggested_directory']
# Should not have a conflict (different names, different directories)
assert data['conflict'] is False
@pytest.mark.asyncio
async def test_get_tag_info_allows_channel_domain_same_directory(
self, async_client, test_session, test_directory, channel_factory
):
"""Test that channel and domain collections can share directories."""
# Create a channel collection
channel = channel_factory(name='test', directory=test_directory / 'videos' / 'WROL' / 'test')
test_session.flush()
# Create a domain collection with a different directory
domain = Collection(
name='example.com',
kind='domain',
directory=test_directory / 'archive' / 'example.com'
)
test_session.add(domain)
test_session.commit()
# Request tag info for domain
request, response = await async_client.post(
f'/api/collections/{domain.id}/tag_info',
json={'tag_name': 'WROL'}
)
# Check response
assert response.status_code == HTTPStatus.OK
data = response.json
# Should not have a conflict (different kinds can share)
assert data['conflict'] is False
@pytest.mark.asyncio
async def test_get_tag_info_detects_exact_directory_conflict(
self, async_client, test_session, test_directory
):
"""Test that tag_info detects conflicts when suggested directory exactly matches existing collection."""
# Create two domain collections with different names but where one's suggested directory
# would conflict with the other's existing directory
# collection1 is already in the "WROL" tagged location
collection1 = Collection(
name='conflicting.com',
kind='domain',
directory=test_directory / 'archive' / 'WROL' / 'example.com'
)
# collection2 is named example.com and would want to move to archive/WROL/example.com when tagged
collection2 = Collection(
name='example.com',
kind='domain',
directory=test_directory / 'archive' / 'example.com'
)
test_session.add_all([collection1, collection2])
test_session.commit()
# Request tag info for collection2 with a tag that would create the same directory as collection1
request, response = await async_client.post(
f'/api/collections/{collection2.id}/tag_info',
json={'tag_name': 'WROL'}
)
# Check response
assert response.status_code == HTTPStatus.OK
data = response.json
# Should suggest archive/WROL/example.com
assert 'suggested_directory' in data
assert 'WROL' in data['suggested_directory']
assert 'example.com' in data['suggested_directory']
# Should have a conflict (same directory as collection1)
assert data['conflict'] is True
assert data['conflict_message'] is not None
assert 'conflicting.com' in data['conflict_message']
@pytest.mark.asyncio
async def test_get_tag_info_unknown_collection(
self, async_client, test_session
):
"""Test that tag_info returns 404 for unknown collection."""
# Request tag info for non-existent collection
request, response = await async_client.post(
'/api/collections/99999/tag_info',
json={'tag_name': 'WROL'}
)
# Check response
assert response.status_code == HTTPStatus.NOT_FOUND
data = response.json
assert 'error' in data
class TestCollectionDeletion:
"""Test the DELETE /api/collections/<id> endpoint."""
@pytest.mark.asyncio
async def test_delete_domain_collection_orphans_archives(
self, async_client, test_session, test_directory
):
"""Test that deleting a domain collection orphans its archives."""
from modules.archive.models import Archive
from wrolpi.files.models import FileGroup
# Create a domain collection
collection = Collection(
name='example.com',
kind='domain',
directory=test_directory / 'archive' / 'example.com'
)
test_session.add(collection)
test_session.flush()
# Create file groups and archives for this domain
fg1 = FileGroup(primary_path=test_directory / 'archive' / 'example.com' / 'page1.html', url='https://example.com/page1')
fg2 = FileGroup(primary_path=test_directory / 'archive' / 'example.com' / 'page2.html', url='https://example.com/page2')
test_session.add_all([fg1, fg2])
test_session.flush()
archive1 = Archive(file_group_id=fg1.id, collection_id=collection.id)
archive2 = Archive(file_group_id=fg2.id, collection_id=collection.id)
test_session.add_all([archive1, archive2])
test_session.commit()
collection_id = collection.id
# Verify archives are associated with the collection
assert test_session.query(Archive).filter_by(collection_id=collection_id).count() == 2
# Delete the collection
request, response = await async_client.delete(f'/api/collections/{collection_id}')
# Check response
assert response.status_code == HTTPStatus.NO_CONTENT
# Verify collection is deleted
assert test_session.query(Collection).filter_by(id=collection_id).count() == 0
# Verify archives are orphaned (collection_id is NULL) but still exist
orphaned_archives = test_session.query(Archive).filter_by(collection_id=None).all()
assert len(orphaned_archives) == 2
assert {a.id for a in orphaned_archives} == {archive1.id, archive2.id}
# Verify archives still exist in total
assert test_session.query(Archive).count() == 2
@pytest.mark.asyncio
async def test_delete_unknown_collection(
self, async_client, test_session
):
"""Test that deleting unknown collection returns 404."""
# Try to delete non-existent collection
request, response = await async_client.delete('/api/collections/99999')
# Check response
assert response.status_code == HTTPStatus.NOT_FOUND
data = response.json
assert 'error' in data
@pytest.mark.asyncio
async def test_delete_channel_collection(
self, async_client, test_session, test_directory, channel_factory
):
"""Test that deleting a channel collection works."""
# Create a channel collection
channel = channel_factory(name='test', directory=test_directory / 'videos' / 'test')
test_session.commit()
collection_id = channel.collection.id
# Delete the collection
request, response = await async_client.delete(f'/api/collections/{collection_id}')
# Check response
assert response.status_code == HTTPStatus.NO_CONTENT
# Verify collection is deleted
assert test_session.query(Collection).filter_by(id=collection_id).count() == 0
class TestCollectionTagging:
"""Test the POST /api/collections/<id>/tag endpoint for tagging and un-tagging."""
@pytest.mark.asyncio
async def test_untag_collection(
self, async_client, test_session, test_directory, tag_factory
):
"""Test that sending tag_name=null removes the tag from a collection."""
# Create a tag
tag = await tag_factory('TestTag')
test_session.flush()
# Create a domain collection with a tag and directory
collection = Collection(
name='example.com',
kind='domain',
directory=test_directory / 'archive' / 'TestTag' / 'example.com',
tag_id=tag.id
)
test_session.add(collection)
test_session.commit()
# Verify collection has the tag
assert collection.tag_id == tag.id
assert collection.tag_name == 'TestTag'
# Send POST request to un-tag (with tag_name: null)
request, response = await async_client.post(
f'/api/collections/{collection.id}/tag',
json={'tag_name': None}
)
# Check response
assert response.status_code == HTTPStatus.OK
# Refresh the collection from database
test_session.refresh(collection)
# Verify the tag has been removed
assert collection.tag_id is None
assert collection.tag_name is None

View file

@ -0,0 +1,119 @@
import pathlib
import pytest
import yaml
from sqlalchemy.orm import Session
from wrolpi.collections import collections_config
from wrolpi.collections.models import Collection
from wrolpi.common import get_media_directory
@pytest.mark.asyncio
async def test_collection_lifecycle_end_to_end(async_client, test_session: Session, test_directory: pathlib.Path, video_factory):
"""
Lifecycle:
- Start with no collections
- Create a directory-restricted collection with a unique directory containing some videos
- Dump to config (collections.yaml)
- Add a new video to that directory; ensure collection fetches show it
- Remove one video from the collection only (files remain)
- Delete another video entirely (files removed and item gone from collection)
- Delete the collection; dump config; files remain (except the deleted video)
"""
# 1) Start clean
assert test_directory.is_dir()
assert test_session.query(Collection).count() == 0
# 2) Prepare unique directory with some videos
coll_dir = test_directory / 'videos' / 'collections_lifecycle' / 'case_001'
coll_dir.mkdir(parents=True, exist_ok=True)
# Create three videos in the directory
v1 = video_factory(with_video_file=coll_dir / 'v1.mp4')
v2 = video_factory(with_video_file=coll_dir / 'v2.mp4')
v3 = video_factory(with_video_file=coll_dir / 'v3.mp4')
test_session.commit()
# Sanity: files exist
assert v1.file_group.primary_path.exists()
assert v2.file_group.primary_path.exists()
assert v3.file_group.primary_path.exists()
# 3) Create a directory-restricted collection via config import semantics
rel_dir = coll_dir.relative_to(get_media_directory())
cfg = {
'name': 'Lifecycle Test Collection',
'description': 'E2E lifecycle test',
'directory': str(rel_dir),
'kind': 'channel',
}
coll = Collection.from_config(cfg, session=test_session)
test_session.commit()
# After creation, items should be auto-populated from directory
items = coll.get_items(session=test_session)
fg_ids = {i.file_group_id for i in items}
assert {v1.file_group_id, v2.file_group_id, v3.file_group_id}.issubset(fg_ids)
# 4) Dump collections config; verify entry present
collections_config.dump_config()
config_file = collections_config.get_file()
assert config_file.is_file(), f"Expected config file at {config_file}"
data = yaml.safe_load(config_file.read_text())
assert isinstance(data, dict)
dumped = data.get('collections', [])
# Config stores absolute paths
assert any(c.get('name') == 'Lifecycle Test Collection' and c.get('directory') == str(coll_dir) and c.get(
'kind') == 'channel' for c in dumped)
# 5) Add a new video to the same directory; ensure fetch methods include it after population
v4 = video_factory(with_video_file=coll_dir / 'v4.mp4')
test_session.commit()
# Populate from directory to pick up new file group
coll.populate_from_directory(session=test_session)
test_session.commit()
items = coll.get_items(session=test_session)
fg_ids = {i.file_group_id for i in items}
assert v4.file_group_id in fg_ids
# 6) Remove one video from the collection only (do not delete the video)
coll.remove_file_group(v2.file_group_id, session=test_session)
test_session.commit()
items = coll.get_items(session=test_session)
fg_ids = {i.file_group_id for i in items}
assert v2.file_group_id not in fg_ids, 'Removed video should not be visible in collection fetch methods'
# Files should remain on disk
assert v2.file_group.primary_path.exists()
# 7) Delete another video entirely (remove files too)
# Use the model's delete to remove files and DB rows
v3.delete()
test_session.commit()
# File should be removed from disk
assert not v3.file_group.primary_path.exists()
# The collection should no longer reference it (FK cascade or absence after populate)
items = coll.get_items(session=test_session)
fg_ids = {i.file_group_id for i in items}
assert v3.file_group_id not in fg_ids
# 8) Finally, delete the collection
test_session.delete(coll)
test_session.commit()
# Dump config again; collection should be removed from config
collections_config.dump_config()
data = yaml.safe_load(config_file.read_text())
dumped = data.get('collections', [])
assert not any(c.get('name') == 'Lifecycle Test Collection' and c.get('directory') == str(rel_dir) for c in dumped)
# All remaining files should still exist except the one for the deleted video
assert v1.file_group.primary_path.exists()
assert v2.file_group.primary_path.exists()
assert v4.file_group.primary_path.exists()

View file

@ -0,0 +1,145 @@
import pathlib
import pathlib
import pytest
import yaml
from sqlalchemy.orm import Session
from wrolpi.collections import collections_config
from wrolpi.collections.models import Collection
from wrolpi.common import get_media_directory
# from wrolpi.switches import await_switches
@pytest.mark.asyncio
async def test_tagging_directory_collection_moves_files_and_updates_config(
test_session: Session,
test_directory: pathlib.Path,
video_factory,
async_client,
tag_factory,
):
# Start clean
assert test_session.query(Collection).count() == 0
# Create source directory and a few videos
src_dir = test_directory / 'videos' / 'collections_tagging' / 'dir_case'
src_dir.mkdir(parents=True, exist_ok=True)
v1 = video_factory(with_video_file=src_dir / 'a1.mp4')
v2 = video_factory(with_video_file=src_dir / 'a2.mp4')
test_session.commit()
# Create a directory-restricted Collection (kind=channel) via config import semantics
rel_dir = src_dir.relative_to(get_media_directory())
cfg = {
'name': 'Funny Clips',
'description': 'Clips that make me laugh',
'directory': str(rel_dir),
'kind': 'channel',
}
coll = Collection.from_config(cfg, session=test_session)
test_session.commit()
# Verify it populated
assert {i.file_group_id for i in coll.get_items(session=test_session)} == {v1.file_group_id, v2.file_group_id}
# Dump config and verify (config stores absolute paths)
collections_config.dump_config()
cfg_file = collections_config.get_file()
data = yaml.safe_load(cfg_file.read_text())
dumped = data.get('collections', [])
assert any(
c.get('name') == 'Funny Clips' and c.get('directory') == str(src_dir) and c.get('kind') == 'channel' for c in
dumped)
# Tag the collection -> should move to videos/<Tag>/<Collection Name>
tag = await tag_factory('Funny')
coll.set_tag(tag.id)
test_session.commit()
# Compute expected destination and move
dest_dir = coll.format_directory('Funny')
dest_dir.mkdir(parents=True, exist_ok=True)
# Perform the move using the model helper
await coll.move_collection(dest_dir, session=test_session)
test_session.commit()
# Validate the directory changed and files moved
assert coll.directory == dest_dir
assert (dest_dir / 'a1.mp4').is_file()
assert (dest_dir / 'a2.mp4').is_file()
assert not (src_dir / 'a1.mp4').exists()
assert not (src_dir / 'a2.mp4').exists()
# FileGroup paths should be in dest_dir now
test_session.refresh(v1)
test_session.refresh(v2)
assert str(dest_dir) in str(v1.file_group.primary_path)
assert str(dest_dir) in str(v2.file_group.primary_path)
# Dump config again and verify updated directory and tag (config stores absolute paths)
collections_config.dump_config()
data = yaml.safe_load(cfg_file.read_text())
dumped = data.get('collections', [])
assert any(
c.get('name') == 'Funny Clips' and c.get('directory') == str(dest_dir) and c.get('tag_name') == 'Funny' for
c in dumped)
@pytest.mark.asyncio
async def test_tagging_unrestricted_collection_updates_config_only(
test_session: Session,
test_directory: pathlib.Path,
make_files_structure,
async_client,
tag_factory,
):
# Create a couple files in different places using make_files_structure
files = [
'videos/loose/x1.mp4',
'videos/misc/x2.mp4',
]
fg1, fg2 = make_files_structure(files, file_groups=True, session=test_session)
test_session.commit()
# Create an unrestricted collection and add both files
coll = Collection.from_config({'name': 'Loose Stuff', 'kind': 'channel'}, session=test_session)
# Unrestricted, so add manually
coll.add_file_groups([fg1, fg2], session=test_session)
test_session.commit()
# Dump config and verify directory is absent/None
collections_config.dump_config()
cfg_file = collections_config.get_file()
data = yaml.safe_load(cfg_file.read_text())
dumped = data.get('collections', [])
entry = next(c for c in dumped if c.get('name') == 'Loose Stuff')
assert 'directory' not in entry
# Tag the collection; files should not move
before_paths = (fg1.primary_path, fg2.primary_path)
# Create a Tag synchronously and assign by name
# from wrolpi.tags import Tag
# tag = Tag(name='Favorites', color='#00ff00')
# test_session.add(tag)
# test_session.commit()
await tag_factory('Favorites')
coll.set_tag('Favorites')
test_session.commit()
# Assert paths unchanged
test_session.refresh(fg1)
test_session.refresh(fg2)
after_paths = (fg1.primary_path, fg2.primary_path)
assert before_paths == after_paths
# Dump config and verify tag written, directory still not set
collections_config.dump_config()
data = yaml.safe_load(cfg_file.read_text())
dumped = data.get('collections', [])
entry = next(c for c in dumped if c.get('name') == 'Loose Stuff')
assert entry.get('tag_name') == 'Favorites'
assert 'directory' not in entry

View file

@ -0,0 +1,230 @@
"""Tests for domain-specific Collection functionality."""
import pytest
from sqlalchemy.orm import Session
from wrolpi.collections.models import Collection
class TestDomainValidation:
"""Test domain name validation for Collections with kind='domain'."""
def test_is_valid_domain_name_valid_domains(self):
"""Test that valid domain names are accepted."""
assert Collection.is_valid_domain_name('example.com') is True
assert Collection.is_valid_domain_name('sub.example.com') is True
assert Collection.is_valid_domain_name('a.b.c') is True
assert Collection.is_valid_domain_name('test.org') is True
assert Collection.is_valid_domain_name('my-site.co.uk') is True
def test_is_valid_domain_name_invalid_domains(self):
"""Test that invalid domain names are rejected."""
# No dots
assert Collection.is_valid_domain_name('example') is False
assert Collection.is_valid_domain_name('localhost') is False
# Starts or ends with dot
assert Collection.is_valid_domain_name('.example.com') is False
assert Collection.is_valid_domain_name('example.com.') is False
assert Collection.is_valid_domain_name('.') is False
# Empty or non-string
assert Collection.is_valid_domain_name('') is False
assert Collection.is_valid_domain_name(None) is False
assert Collection.is_valid_domain_name(123) is False
def test_from_config_validates_domain_kind(self, test_session: Session):
"""Test that from_config enforces domain validation when kind='domain'."""
# Valid domain should succeed
config = {
'name': 'example.com',
'kind': 'domain',
}
collection = Collection.from_config(config, session=test_session)
assert collection.name == 'example.com'
assert collection.kind == 'domain'
assert collection.directory is None
# Invalid domain should raise ValueError
invalid_config = {
'name': 'invalid-no-dot',
'kind': 'domain',
}
with pytest.raises(ValueError, match='Invalid domain name'):
Collection.from_config(invalid_config, session=test_session)
# Domain starting with dot should fail
invalid_config2 = {
'name': '.example.com',
'kind': 'domain',
}
with pytest.raises(ValueError, match='Invalid domain name'):
Collection.from_config(invalid_config2, session=test_session)
# Domain ending with dot should fail
invalid_config3 = {
'name': 'example.com.',
'kind': 'domain',
}
with pytest.raises(ValueError, match='Invalid domain name'):
Collection.from_config(invalid_config3, session=test_session)
def test_from_config_allows_any_name_for_channel_kind(self, test_session: Session):
"""Test that channel kind does not enforce domain validation."""
# Channel kind should allow any name (no domain validation)
config = {
'name': 'My Channel Without Dots',
'kind': 'channel',
}
collection = Collection.from_config(config, session=test_session)
assert collection.name == 'My Channel Without Dots'
assert collection.kind == 'channel'
def test_domain_collection_unrestricted_mode(self, test_session: Session):
"""Test that domain collections can be created without directory (unrestricted)."""
config = {
'name': 'example.org',
'kind': 'domain',
'description': 'Archives from example.org',
}
collection = Collection.from_config(config, session=test_session)
test_session.commit()
assert collection.name == 'example.org'
assert collection.kind == 'domain'
assert collection.directory is None
assert collection.is_directory_restricted is False
assert collection.description == 'Archives from example.org'
def test_domain_collection_with_subdomain(self, test_session: Session):
"""Test that subdomains are valid domain names."""
config = {
'name': 'blog.example.com',
'kind': 'domain',
}
collection = Collection.from_config(config, session=test_session)
test_session.commit()
assert collection.name == 'blog.example.com'
assert collection.kind == 'domain'
def test_update_existing_domain_collection(self, test_session: Session):
"""Test that updating a domain collection preserves validation."""
# Create initial domain collection
config = {
'name': 'example.com',
'kind': 'domain',
'description': 'Initial description',
}
collection = Collection.from_config(config, session=test_session)
test_session.commit()
collection_id = collection.id
# Update with valid domain
updated_config = {
'name': 'example.com',
'kind': 'domain',
'description': 'Updated description',
}
updated = Collection.from_config(updated_config, session=test_session)
assert updated.id == collection_id
assert updated.description == 'Updated description'
def test_domain_collection_to_config(self, test_session: Session):
"""Test that domain collections export correctly to config."""
config = {
'name': 'test.org',
'kind': 'domain',
'description': 'Test domain',
}
collection = Collection.from_config(config, session=test_session)
test_session.commit()
exported = collection.to_config()
assert exported['name'] == 'test.org'
assert exported['kind'] == 'domain'
assert exported['description'] == 'Test domain'
assert 'directory' not in exported # Should not have directory
def test_get_by_name_and_kind(self, test_session: Session):
"""Test finding domain collections by name and kind."""
# Create two collections with same name but different kinds
domain_config = {
'name': 'example.com',
'kind': 'domain',
}
channel_config = {
'name': 'example.com',
'kind': 'channel',
}
domain_coll = Collection.from_config(domain_config, session=test_session)
channel_coll = Collection.from_config(channel_config, session=test_session)
test_session.commit()
# Both should exist with different IDs
assert domain_coll.id != channel_coll.id
assert domain_coll.kind == 'domain'
assert channel_coll.kind == 'channel'
# Finding by name and kind should return the correct one
found_domain = test_session.query(Collection).filter_by(
name='example.com',
kind='domain'
).first()
assert found_domain.id == domain_coll.id
class TestDirectoryValidation:
"""Test that collection directories must be under media directory."""
def test_from_config_rejects_absolute_path_outside_media_directory(self, test_session, test_directory):
"""Test that from_config rejects absolute paths outside media directory."""
from wrolpi.errors import ValidationError
# Try to create collection with directory outside media directory
config = {
'name': 'example.com',
'kind': 'domain',
'directory': '/opt/wrolpi/archive/example.com' # Outside media directory!
}
# Should raise ValidationError
with pytest.raises(ValidationError, match="must be under media directory"):
Collection.from_config(config, test_session)
def test_from_config_accepts_relative_path(self, test_session, test_directory):
"""Test that from_config converts relative paths to absolute under media directory."""
from wrolpi.common import get_media_directory
config = {
'name': 'example.com',
'kind': 'domain',
'directory': 'archive/example.com' # Relative path
}
collection = Collection.from_config(config, test_session)
test_session.flush()
# Should be converted to absolute path under media directory
assert collection.directory.is_absolute()
assert str(collection.directory).startswith(str(get_media_directory()))
assert collection.directory.name == 'example.com'
def test_from_config_accepts_absolute_path_under_media_directory(self, test_session, test_directory):
"""Test that from_config accepts absolute paths that are under media directory."""
from wrolpi.common import get_media_directory
media_dir = get_media_directory()
valid_path = media_dir / 'archive' / 'example.com'
config = {
'name': 'example.com',
'kind': 'domain',
'directory': str(valid_path)
}
collection = Collection.from_config(config, test_session)
test_session.flush()
# Should accept the path
assert collection.directory == valid_path

View file

@ -0,0 +1,215 @@
"""Tests for DomainsConfig functionality."""
import pathlib
import yaml
from sqlalchemy.orm import Session
from modules.archive.lib import DomainsConfig, domains_config
from wrolpi.collections import Collection
class TestDomainsConfig:
"""Test domains.yaml config file operations."""
def test_domains_config_import_creates_domain_collections(self, test_session: Session, test_directory: pathlib.Path,
async_client):
"""Test that importing domains.yaml creates domain collections."""
config_file = test_directory / 'domains.yaml'
config_data = {
'version': 0,
'collections': [
{
'name': 'example.com',
'kind': 'domain',
'description': 'Archives from example.com',
},
{
'name': 'wikipedia.org',
'kind': 'domain',
},
]
}
config_file.write_text(yaml.dump(config_data))
# Import the config
config = DomainsConfig()
config.import_config(file=config_file)
# Verify collections were created
collections = test_session.query(Collection).filter_by(kind='domain').all()
assert len(collections) == 2
example = test_session.query(Collection).filter_by(name='example.com', kind='domain').first()
assert example is not None
assert example.description == 'Archives from example.com'
assert example.directory is None # Domain collections should be unrestricted
wiki = test_session.query(Collection).filter_by(name='wikipedia.org', kind='domain').first()
assert wiki is not None
def test_domains_config_dump_exports_only_domain_collections(self, test_session: Session,
test_directory: pathlib.Path, async_client):
"""Test that dumping domains.yaml only exports domain collections."""
# Create some domain and channel collections
domain1 = Collection.from_config({'name': 'example.com', 'kind': 'domain'}, session=test_session)
domain2 = Collection.from_config({'name': 'test.org', 'kind': 'domain'}, session=test_session)
channel = Collection.from_config({'name': 'My Channel', 'kind': 'channel'}, session=test_session)
test_session.commit()
# Dump to config
config_file = test_directory / 'domains.yaml'
config = DomainsConfig()
config.dump_config(file=config_file)
# Read and verify
data = yaml.safe_load(config_file.read_text())
assert 'collections' in data
assert len(data['collections']) == 2 # Only domain collections
names = {c['name'] for c in data['collections']}
assert 'example.com' in names
assert 'test.org' in names
assert 'My Channel' not in names # Channel should not be in domains.yaml
# All should have kind='domain'
for coll in data['collections']:
assert coll['kind'] == 'domain'
def test_domains_config_enforces_domain_validation(self, test_session: Session, test_directory: pathlib.Path,
async_client):
"""Test that importing invalid domain names skips them (logs error but doesn't fail import)."""
config_file = test_directory / 'domains.yaml'
config_data = {
'version': 0,
'collections': [
{
'name': 'invalid-domain', # No dot
'kind': 'domain',
},
]
}
config_file.write_text(yaml.dump(config_data))
config = DomainsConfig()
# Import should succeed but skip invalid domain (error is logged)
config.import_config(file=config_file)
# No collections should be created
domains = test_session.query(Collection).filter_by(kind='domain').all()
assert len(domains) == 0
def test_domains_config_forces_kind_to_domain(self, test_session: Session, test_directory: pathlib.Path,
async_client):
"""Test that DomainsConfig forces kind='domain' even if not specified."""
config_file = test_directory / 'domains.yaml'
config_data = {
'version': 0,
'collections': [
{
'name': 'example.com',
# kind not specified
},
]
}
config_file.write_text(yaml.dump(config_data))
config = DomainsConfig()
config.import_config(file=config_file)
# Should be created with kind='domain'
collection = test_session.query(Collection).filter_by(name='example.com').first()
assert collection is not None
assert collection.kind == 'domain'
def test_domains_config_removes_deleted_domains(self, test_session: Session, test_directory: pathlib.Path,
async_client):
"""Test that domains removed from config are deleted from database."""
# Create two domain collections
domain1 = Collection.from_config({'name': 'example.com', 'kind': 'domain'}, session=test_session)
domain2 = Collection.from_config({'name': 'test.org', 'kind': 'domain'}, session=test_session)
test_session.commit()
# Import config with only one domain
config_file = test_directory / 'domains.yaml'
config_data = {
'version': 0,
'collections': [
{'name': 'example.com', 'kind': 'domain'},
]
}
config_file.write_text(yaml.dump(config_data))
config = DomainsConfig()
config.import_config(file=config_file)
# test.org should be deleted
remaining = test_session.query(Collection).filter_by(kind='domain').all()
assert len(remaining) == 1
assert remaining[0].name == 'example.com'
def test_domains_config_updates_existing_domain(self, test_session: Session, test_directory: pathlib.Path,
async_client):
"""Test that updating a domain in config updates the existing collection."""
# Create initial domain
domain = Collection.from_config({
'name': 'example.com',
'kind': 'domain',
'description': 'Original description',
}, session=test_session)
test_session.commit()
domain_id = domain.id
# Import config with updated description
config_file = test_directory / 'domains.yaml'
config_data = {
'version': 0,
'collections': [
{
'name': 'example.com',
'kind': 'domain',
'description': 'Updated description',
},
]
}
config_file.write_text(yaml.dump(config_data))
config = DomainsConfig()
config.import_config(file=config_file)
# Should update existing, not create new
all_domains = test_session.query(Collection).filter_by(kind='domain').all()
assert len(all_domains) == 1
assert all_domains[0].id == domain_id
assert all_domains[0].description == 'Updated description'
def test_domains_config_skips_invalid_entries(self, test_session: Session, test_directory: pathlib.Path,
async_client):
"""Test that invalid entries are skipped but valid ones are imported."""
config_file = test_directory / 'domains.yaml'
config_data = {
'version': 0,
'collections': [
{'name': 'example.com', 'kind': 'domain'}, # Valid
{'name': 'invalid', 'kind': 'domain'}, # Invalid - no dot
{'name': 'test.org', 'kind': 'domain'}, # Valid
]
}
config_file.write_text(yaml.dump(config_data))
config = DomainsConfig()
config.import_config(file=config_file)
# Should have imported 2 valid domains, skipped 1 invalid
domains = test_session.query(Collection).filter_by(kind='domain').all()
assert len(domains) == 2
names = {d.name for d in domains}
assert 'example.com' in names
assert 'test.org' in names
assert 'invalid' not in names
def test_domains_config_global_instance(self):
"""Test that the global domains_config instance exists."""
assert domains_config is not None
assert isinstance(domains_config, DomainsConfig)
assert domains_config.file_name == 'domains.yaml'

137
wrolpi/collections/types.py Normal file
View file

@ -0,0 +1,137 @@
"""
Collection Type Registry
This module provides a registry system for different collection types (domains, channels, etc.)
to register their own validators and behavior rules.
"""
from typing import Callable, Dict, Optional
from wrolpi.common import logger
logger = logger.getChild(__name__)
# Type alias for validator functions
ValidatorFunc = Callable[[str], bool]
class CollectionTypeRegistry:
"""
Registry for collection type validators and rules.
Allows different collection types to register their own validation logic,
making the system extensible for future collection types.
"""
def __init__(self):
self._validators: Dict[str, ValidatorFunc] = {}
self._descriptions: Dict[str, str] = {}
def register(self, kind: str, validator: ValidatorFunc, description: str = ""):
"""
Register a validator for a collection type.
Args:
kind: The collection kind (e.g., 'domain', 'channel')
validator: Function that takes a name and returns True if valid
description: Human-readable description of validation rules
"""
if kind in self._validators:
logger.warning(f"Overwriting existing validator for collection kind '{kind}'")
self._validators[kind] = validator
self._descriptions[kind] = description
logger.debug(f"Registered validator for collection kind '{kind}'")
def validate(self, kind: str, name: str) -> bool:
"""
Validate a collection name for a given type.
Args:
kind: The collection kind
name: The collection name to validate
Returns:
True if valid, False otherwise
If no validator is registered for the kind, returns True (permissive)
"""
if kind not in self._validators:
logger.debug(f"No validator registered for kind '{kind}', allowing any name")
return True
return self._validators[kind](name)
def get_description(self, kind: str) -> Optional[str]:
"""Get the validation description for a collection type."""
return self._descriptions.get(kind)
def is_registered(self, kind: str) -> bool:
"""Check if a validator is registered for a collection type."""
return kind in self._validators
# Global registry instance
collection_type_registry = CollectionTypeRegistry()
# Domain validator
def validate_domain_name(name: str) -> bool:
"""
Validate that a name is a valid domain format.
A valid domain must:
- Be a string
- Contain at least one "." (e.g., "example.com")
- Not start or end with "."
Examples:
Valid: "example.com", "sub.example.com", "a.b.c"
Invalid: "example", "example.", ".example", "."
Args:
name: The domain name to validate
Returns:
True if valid domain format, False otherwise
"""
if not isinstance(name, str) or not name:
return False
# Must contain at least one "."
if '.' not in name:
return False
# Should not start or end with "."
if name.startswith('.') or name.endswith('.'):
return False
return True
# Channel validator (permissive - allows any non-empty string)
def validate_channel_name(name: str) -> bool:
"""
Validate that a name is valid for a channel.
Channels are permissive and allow any non-empty string.
Args:
name: The channel name to validate
Returns:
True if valid (non-empty string), False otherwise
"""
return isinstance(name, str) and len(name.strip()) > 0
# Register built-in collection types
collection_type_registry.register(
'domain',
validate_domain_name,
'Domain must contain at least one "." and not start/end with "."'
)
collection_type_registry.register(
'channel',
validate_channel_name,
'Channel name must be a non-empty string'
)

View file

@ -24,8 +24,9 @@ from functools import wraps
from itertools import islice, filterfalse, tee
from multiprocessing.managers import DictProxy
from pathlib import Path
from types import GeneratorType, MappingProxyType
from typing import Union, Callable, Tuple, Dict, List, Iterable, Optional, Generator, Any, Set
from types import GeneratorType
from typing import Optional, List
from typing import Union, Callable, Tuple, Dict, Iterable, Generator, Any, Set
from urllib.parse import urlparse, urlunsplit
import aiohttp
@ -731,6 +732,10 @@ def get_all_configs() -> Dict[str, ConfigFile]:
if download_manager_config := get_download_manager_config():
all_configs[download_manager_config.file_name] = download_manager_config
from modules.archive.lib import get_domains_config
if domains_config := get_domains_config():
all_configs[domains_config.file_name] = domains_config
return all_configs
@ -1173,16 +1178,21 @@ def make_media_directory(path: Union[str, Path]):
def extract_domain(url: str) -> str:
"""
Extract the domain from a URL. Remove leading www.
Extract the domain from a URL. Remove leading www and port.
>>> extract_domain('https://www.example.com/foo')
'example.com'
>>> extract_domain('https://example.com:443/foo')
'example.com'
"""
parsed = urlparse(url)
domain = parsed.netloc
if not domain:
raise ValueError(f'URL does not have a domain: {url=}')
domain = domain.decode() if hasattr(domain, 'decode') else domain
# Remove port if present
if ':' in domain:
domain = domain.split(':')[0]
if domain.startswith('www.'):
# Remove leading www.
domain = domain[4:]
@ -1902,7 +1912,8 @@ def extract_html_text(html: str) -> str:
for script in soup(["script", "style"]):
script.extract() # rip it out
text = soup.body.get_text()
# Use body if it exists, otherwise use the entire soup
text = soup.body.get_text() if soup.body else soup.get_text()
# break into lines and remove leading and trailing space on each
lines = (line.strip() for line in text.splitlines())
@ -1951,7 +1962,8 @@ async def search_other_estimates(tag_names: List[str]) -> dict:
stmt = '''
SELECT COUNT(c.id)
FROM channel c
LEFT OUTER JOIN public.tag t on t.id = c.tag_id
INNER JOIN collection col ON col.id = c.collection_id
LEFT OUTER JOIN public.tag t on t.id = col.tag_id
WHERE t.name = %(tag_name)s \
'''
# TODO handle multiple tags

View file

@ -9,7 +9,6 @@ import logging
import multiprocessing
import pathlib
import shutil
import sys
import tempfile
import threading
import zipfile
@ -19,14 +18,13 @@ from itertools import zip_longest
from typing import List, Callable, Dict, Sequence, Union, Coroutine, Awaitable, Optional
from typing import Tuple, Set
from unittest import mock
from unittest.mock import MagicMock, AsyncMock, patch
from unittest.mock import MagicMock, AsyncMock
from uuid import uuid1, uuid4
import pytest
import sqlalchemy
import yaml
from PIL import Image
from sanic_testing.reusable import ReusableClient
from sanic_testing.testing import SanicASGITestClient
from sqlalchemy.engine import Engine, create_engine
from sqlalchemy.orm import Session, sessionmaker
@ -143,31 +141,6 @@ def test_wrolpi_config(test_directory) -> pathlib.Path:
ROUTES_ATTACHED = False
@pytest.fixture()
def test_client(test_directory) -> ReusableClient:
"""Get a Reusable Sanic Test Client with all default routes attached.
(A non-reusable client would turn on for each request)
"""
attach_shared_contexts(api_app)
initialize_configs_contexts(api_app)
for _ in range(5):
# Sometimes the Sanic client tries to use a port already in use, try again...
try:
client = ReusableClient(api_app)
with client:
yield client
break
except OSError as e:
# Ignore errors where the port was already in use.
if 'address already in use' not in str(e):
raise
else:
raise RuntimeError('Test never got unused port')
@api_app.on_response
async def background_task_listener(request, response):
"""Wait for all background tasks to finish before returning API response while testing."""
@ -623,12 +596,12 @@ def assert_file_groups(test_session, test_directory):
@pytest.fixture
def assert_files_search(test_client):
def assert_files_search(async_client):
from wrolpi.test.common import assert_dict_contains
def _(search_str: str, expected: List[dict]):
async def _(search_str: str, expected: List[dict]):
content = json.dumps({'search_str': search_str})
request, response = test_client.post('/api/files/search', content=content)
request, response = await async_client.post('/api/files/search', content=content)
for file_group, exp in zip_longest(response.json['file_groups'], expected):
assert_dict_contains(file_group, exp)

View file

@ -94,6 +94,12 @@ def reset_shared_contexts(app: Sanic):
drives_stats=list(),
processes_stats=list(),
memory_stats=dict(),
# Upgrade info defaults
update_available=False,
latest_commit=None,
current_commit=None,
commits_behind=0,
git_branch=None,
))
app.shared_ctx.map_importing.clear()
app.shared_ctx.cache.clear()

View file

@ -117,9 +117,10 @@ class Download(ModelHelper, Base): # noqa
status = Column(String, default=DownloadStatus.new) # `DownloadStatus` enum.
tag_names = Column(ARRAY(Text))
# A Download may be associated with a Channel (downloads all Channel videos, or a playlist, etc.).
channel_id = Column(Integer, ForeignKey('channel.id'))
channel = relationship('Channel', primaryjoin='Download.channel_id==Channel.id', back_populates='downloads')
# A Download may be associated with a Collection (downloads for channels, domains, etc.).
collection_id = Column(Integer, ForeignKey('collection.id', ondelete='SET NULL'))
collection = relationship('Collection', primaryjoin='Download.collection_id==Collection.id',
back_populates='downloads')
def __repr__(self):
if self.next_download or self.frequency:
@ -132,7 +133,7 @@ class Download(ModelHelper, Base): # noqa
def __json__(self) -> dict:
d = dict(
attempts=self.attempts,
channel_id=self.channel_id,
collection_id=self.collection_id,
downloader=self.downloader,
frequency=self.frequency,
destination=self.destination,
@ -226,12 +227,24 @@ class Download(ModelHelper, Base): # noqa
session = Session.object_session(self)
# Get collection info before deleting
collection = self.collection
collection_kind = collection.kind if collection else None
session.delete(self)
session.commit()
if self.channel_id:
# Save Channels config if this download was associated with a Channel.
# Save appropriate config based on collection kind
if collection_kind == 'channel':
from modules.videos.lib import save_channels_config
save_channels_config.activate_switch()
elif collection_kind == 'domain':
from modules.archive.lib import save_domains_config
save_domains_config.activate_switch()
elif collection_kind:
from wrolpi.collections.config import save_collections_config
save_collections_config.activate_switch()
# Save download config again because this download is now removed from the download lists.
save_downloads_config.activate_switch()
@ -612,9 +625,9 @@ class DownloadManager:
download.tag_names = tag_names or None
# Preserve existing settings, unless new settings are provided.
download.settings = settings if settings is not None else download.settings
if download.frequency and download.settings and (channel_id := download.settings.get('channel_id')):
# Attach a recurring Channel download to it's Channel.
download.channel_id = download.channel_id or channel_id
if download.frequency and download.settings and (collection_id := download.settings.get('collection_id')):
# Attach a recurring download to its Collection.
download.collection_id = download.collection_id or collection_id
downloads.append(download)
@ -647,7 +660,7 @@ class DownloadManager:
def recurring_download(self, url: str, frequency: int, downloader_name: str, session: Session = None,
sub_downloader_name: str = None, reset_attempts: bool = False,
destination: str | pathlib.Path = None, tag_names: List[str] = None,
settings: Dict = None) -> Download:
settings: Dict = None, collection_id: int = None) -> Download:
"""Schedule a recurring download."""
if not frequency or not isinstance(frequency, int):
raise ValueError('Recurring download must have a frequency!')
@ -660,11 +673,13 @@ class DownloadManager:
reset_attempts=reset_attempts, sub_downloader_name=sub_downloader_name,
destination=destination, tag_names=tag_names, settings=settings)
download.frequency = frequency
if collection_id:
download.collection_id = collection_id
# Only recurring Downloads can be Channel Downloads.
# Only recurring Downloads can be Collection Downloads - look up via Channel.
from modules.videos.models import Channel
if channel := Channel.get_by_url(url=download.url, session=session):
download.channel_id = channel.id
download.collection_id = channel.collection_id
session.commit()
@ -674,10 +689,11 @@ class DownloadManager:
def update_download(self, id_: int, url: str, downloader: str,
destination: str | pathlib.Path = None, tag_names: List[str] = None,
sub_downloader: str | None = None, frequency: int = None,
settings: Dict = None, session: Session = None) -> Download:
settings: Dict = None, collection_id: int = None,
session: Session = None) -> Download:
download = Download.find_by_id(id_, session=session)
if settings and settings.get('channel_id') and not frequency:
raise InvalidDownload(f'A once-download cannot be associated with a Channel')
if collection_id and not frequency:
raise InvalidDownload(f'A once-download cannot be associated with a Collection')
download.url = url
download.downloader = downloader
download.frequency = frequency
@ -687,8 +703,8 @@ class DownloadManager:
download.destination = destination or None
download.tag_names = tag_names or None
download.sub_downloader = sub_downloader or None
# Remove Channel relationship, if necessary.
download.channel_id = (settings or dict()).get('channel_id')
# Update Collection relationship
download.collection_id = collection_id
save_downloads_config.activate_switch()
@ -939,7 +955,7 @@ class DownloadManager:
with get_db_curs() as curs:
stmt = f'''
SELECT
channel_id,
collection_id,
destination,
downloader,
error,
@ -1462,7 +1478,7 @@ class RSSDownloader(Downloader):
# Apply YT channel to the Download, if not already applied.
if yt_channel_id := feed.get('feed', dict()).get('yt_channelid'):
if not (download.location or download.channel_id):
if not (download.location or download.collection_id):
self.apply_yt_channel(download.id, yt_channel_id)
# Filter entries using Download.settings.
@ -1562,14 +1578,14 @@ class RSSDownloader(Downloader):
@staticmethod
def apply_yt_channel(download_id: int, yt_channel_id: str):
"""Get Channel that matches this Download, apply Channel information to the Download."""
"""Get Channel that matches this Download, apply Channel/Collection information to the Download."""
with get_db_session() as session:
from modules.videos.models import Channel
channel = Channel.get_by_source_id(session, f'UC{yt_channel_id}')
if channel:
download_ = Download.get_by_id(download_id, session=session)
download_.channel = channel
download_.channel_id = channel.id
download_.collection = channel.collection
download_.collection_id = channel.collection_id
download_.location = download_.location or channel.location
session.commit()

View file

@ -187,3 +187,9 @@ class DownloadError(APIError):
code = 'DOWNLOAD_ERROR'
summary = 'Unable to complete download'
status_code = HTTPStatus.INTERNAL_SERVER_ERROR
class UnknownCollection(APIError):
code = 'UNKNOWN_COLLECTION'
summary = 'Unable to find the collection'
status_code = HTTPStatus.NOT_FOUND

View file

@ -108,6 +108,10 @@ class Events:
def send_screenshot_generation_failed(cls, message: str = None):
send_event('screenshot_generation_failed', message, subject='screenshot')
@staticmethod
def send_upgrade_started(message: str = None):
send_event('upgrade_started', message, subject='upgrade')
def log_event(event: str, message: str = None, action: str = None, subject: str = None):
log = f'{event=}'

Some files were not shown because too many files have changed in this diff Show more