Skip to content

Commit 51706b3

Browse files
lukischclaude
andcommitted
feat: PySide6 migration, theme engine, GUI virtualization, tag system
- Migrate all PyQt6 imports to PySide6 (LGPL compliance) - Add ThemeEngine with 3 switchable themes (Light, Dark, High Contrast) - Central QSS stylesheet system (350+ lines), no more scattered styles - Migrate FavoritesView and GlobalSearchView to QListView + custom delegate - Add TagManager with full CRUD, DB persistence, AND-filter logic - Tag chips in item delegates, context menu tag assignment - 20 new unit tests for tag system (99/99 total passing) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent aca3c05 commit 51706b3

8 files changed

Lines changed: 1499 additions & 182 deletions

File tree

MediaBrain.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@
66
import queue
77
import os
88
import sys
9-
from PyQt6.QtWidgets import QApplication
10-
from PyQt6.QtCore import QTimer # Import nach oben verschoben
9+
from PySide6.QtWidgets import QApplication
10+
from PySide6.QtCore import QTimer # Import nach oben verschoben
1111

1212
# Projektordner zum Pfad hinzufügen
1313
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
1414

15-
from core import Database, MediaManager, BlacklistManager, EventProcessor
15+
from core import Database, MediaManager, BlacklistManager, EventProcessor, TagManager
1616
from gui import MainWindow
1717
import background
1818
import config
@@ -21,19 +21,20 @@
2121
class AppController:
2222
def __init__(self):
2323
logger.info("Starte MediaBrain...")
24-
24+
2525
# 1. Core Komponenten
2626
self.db = Database(config.DB_PATH)
2727
self.media_manager = MediaManager(self.db)
2828
self.blacklist_manager = BlacklistManager(self.db)
29+
self.tag_manager = TagManager(self.db)
2930

3031
# 2. Event Processor (Verbindung zwischen Background & GUI)
3132
self.event_processor = EventProcessor(self.media_manager)
3233
self.event_processor.queue = queue.Queue()
3334

3435
# 3. GUI starten
3536
self.app = QApplication(sys.argv)
36-
self.window = MainWindow(self.media_manager, self.blacklist_manager)
37+
self.window = MainWindow(self.media_manager, self.blacklist_manager, self.tag_manager)
3738

3839
# GUI Refresh verbinden
3940
self.event_processor.on_data_changed = self.window.refresh_all_views

core.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,29 @@ def _setup(self):
8989
# Composite Index für Blacklist-Ablaufprüfung (procedure_code + blacklisted_at)
9090
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_media_blacklist_expiry ON media_items(blacklist_flag, procedure_code, blacklisted_at);")
9191

92+
# Tag-System (Many-to-Many)
93+
self.conn.execute("""
94+
CREATE TABLE IF NOT EXISTS tags (
95+
id INTEGER PRIMARY KEY AUTOINCREMENT,
96+
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
97+
color TEXT DEFAULT '#607D8B',
98+
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
99+
);
100+
""")
101+
102+
self.conn.execute("""
103+
CREATE TABLE IF NOT EXISTS media_tags (
104+
media_id INTEGER NOT NULL,
105+
tag_id INTEGER NOT NULL,
106+
PRIMARY KEY (media_id, tag_id),
107+
FOREIGN KEY (media_id) REFERENCES media_items(id) ON DELETE CASCADE,
108+
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
109+
);
110+
""")
111+
112+
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_media_tags_media ON media_tags(media_id);")
113+
self.conn.execute("CREATE INDEX IF NOT EXISTS idx_media_tags_tag ON media_tags(tag_id);")
114+
92115
self.conn.commit()
93116

94117
def execute(self, query: str, params: Tuple = ()) -> sqlite3.Cursor:
@@ -541,6 +564,158 @@ def list_blacklisted(self, source=None, procedure_code=None, limit=200):
541564

542565
rows = self.db.fetchall(query, tuple(params))
543566
return [MediaItem(r) for r in rows]
567+
568+
def list_by_type_with_tags(self, media_type, tag_ids=None, limit=500):
569+
"""Lists items by type, optionally filtered by tags.
570+
571+
Args:
572+
media_type: Media type string (movie, series, etc.)
573+
tag_ids: Optional list of tag IDs to filter by (AND logic)
574+
limit: Maximum results
575+
"""
576+
if not tag_ids:
577+
return self.list_by_type(media_type, limit)
578+
579+
# Items that have ALL specified tags
580+
placeholders = ",".join("?" * len(tag_ids))
581+
rows = self.db.fetchall(f"""
582+
SELECT m.* FROM media_items m
583+
INNER JOIN media_tags mt ON m.id = mt.media_id
584+
WHERE m.type = ? AND m.blacklist_flag = 0
585+
AND mt.tag_id IN ({placeholders})
586+
GROUP BY m.id
587+
HAVING COUNT(DISTINCT mt.tag_id) = ?
588+
ORDER BY m.is_favorite DESC, m.last_opened_at DESC
589+
LIMIT ?
590+
""", (media_type, *tag_ids, len(tag_ids), limit))
591+
return [MediaItem(r) for r in rows]
592+
593+
594+
# ============================================================
595+
# 4b. TagManager
596+
# ============================================================
597+
598+
class TagManager:
599+
"""Manages tags and media-tag associations.
600+
601+
Tags are stored in a separate table with a many-to-many relationship
602+
to media_items via the media_tags junction table.
603+
"""
604+
605+
def __init__(self, db: Database):
606+
self.db = db
607+
608+
def create_tag(self, name: str, color: str = "#607D8B") -> int:
609+
"""Creates a new tag. Returns the tag ID.
610+
611+
Args:
612+
name: Tag name (case-insensitive unique)
613+
color: Hex color string for display
614+
615+
Returns:
616+
ID of the created or existing tag
617+
"""
618+
name = name.strip()
619+
if not name:
620+
raise ValueError("Tag name cannot be empty")
621+
if len(name) > 50:
622+
raise ValueError("Tag name too long (max 50 chars)")
623+
624+
# Check if exists (COLLATE NOCASE handles case)
625+
existing = self.db.fetchone(
626+
"SELECT id FROM tags WHERE name = ?", (name,)
627+
)
628+
if existing:
629+
return existing["id"]
630+
631+
cur = self.db.execute(
632+
"INSERT INTO tags (name, color) VALUES (?, ?)",
633+
(name, color)
634+
)
635+
return cur.lastrowid
636+
637+
def delete_tag(self, tag_id: int):
638+
"""Deletes a tag and all its associations."""
639+
self.db.execute("DELETE FROM media_tags WHERE tag_id = ?", (tag_id,))
640+
self.db.execute("DELETE FROM tags WHERE id = ?", (tag_id,))
641+
642+
def rename_tag(self, tag_id: int, new_name: str):
643+
"""Renames a tag."""
644+
new_name = new_name.strip()
645+
if not new_name:
646+
raise ValueError("Tag name cannot be empty")
647+
self.db.execute(
648+
"UPDATE tags SET name = ? WHERE id = ?", (new_name, tag_id)
649+
)
650+
651+
def list_tags(self) -> list:
652+
"""Returns all tags with usage count, sorted by usage (descending).
653+
654+
Returns:
655+
List of dicts with keys: id, name, color, count
656+
"""
657+
rows = self.db.fetchall("""
658+
SELECT t.id, t.name, t.color, COUNT(mt.media_id) AS count
659+
FROM tags t
660+
LEFT JOIN media_tags mt ON t.id = mt.tag_id
661+
GROUP BY t.id
662+
ORDER BY count DESC, t.name ASC
663+
""")
664+
return [dict(row) for row in rows]
665+
666+
def add_tag_to_media(self, media_id: int, tag_id: int):
667+
"""Associates a tag with a media item (idempotent)."""
668+
try:
669+
self.db.execute(
670+
"INSERT OR IGNORE INTO media_tags (media_id, tag_id) VALUES (?, ?)",
671+
(media_id, tag_id)
672+
)
673+
except Exception as e:
674+
logger.warning("Could not add tag %d to media %d: %s", tag_id, media_id, e)
675+
676+
def remove_tag_from_media(self, media_id: int, tag_id: int):
677+
"""Removes a tag association from a media item."""
678+
self.db.execute(
679+
"DELETE FROM media_tags WHERE media_id = ? AND tag_id = ?",
680+
(media_id, tag_id)
681+
)
682+
683+
def get_tags_for_media(self, media_id: int) -> list:
684+
"""Returns all tags for a specific media item.
685+
686+
Returns:
687+
List of dicts with keys: id, name, color
688+
"""
689+
rows = self.db.fetchall("""
690+
SELECT t.id, t.name, t.color
691+
FROM tags t
692+
INNER JOIN media_tags mt ON t.id = mt.tag_id
693+
WHERE mt.media_id = ?
694+
ORDER BY t.name
695+
""", (media_id,))
696+
return [dict(row) for row in rows]
697+
698+
def get_media_ids_by_tags(self, tag_ids: list) -> list:
699+
"""Returns media IDs that have ALL specified tags (AND logic).
700+
701+
Args:
702+
tag_ids: List of tag IDs
703+
704+
Returns:
705+
List of media item IDs
706+
"""
707+
if not tag_ids:
708+
return []
709+
placeholders = ",".join("?" * len(tag_ids))
710+
rows = self.db.fetchall(f"""
711+
SELECT media_id FROM media_tags
712+
WHERE tag_id IN ({placeholders})
713+
GROUP BY media_id
714+
HAVING COUNT(DISTINCT tag_id) = ?
715+
""", (*tag_ids, len(tag_ids)))
716+
return [row["media_id"] for row in rows]
717+
718+
544719
# ============================================================
545720
# 5. EventProcessor
546721
# ============================================================

0 commit comments

Comments
 (0)