@@ -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