From 6430fe07453acfc46f30344eff919ddea671b0cf Mon Sep 17 00:00:00 2001 From: Pirates IRC <98669745+PiratesIRC@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:30:19 -0500 Subject: [PATCH] v0.2 --- Stream-Mapparr/channels.txt | 475 ++++++++------- Stream-Mapparr/plugin.py | 1109 ++++++++++++++++++++++++++--------- 2 files changed, 1101 insertions(+), 483 deletions(-) diff --git a/Stream-Mapparr/channels.txt b/Stream-Mapparr/channels.txt index fdb96f3..eb30cb5 100644 --- a/Stream-Mapparr/channels.txt +++ b/Stream-Mapparr/channels.txt @@ -17,14 +17,6 @@ AABC TV ABC ABC News Live ACC Network -AIT -ALL ACTION -AMC -AMGA TV -ART -ARTN -AWE -AXS TV AccuWeather Network Acme Crime Net Action-Packed TV @@ -36,13 +28,16 @@ Afro Kiddos Afrotainment Afrotainment Music Aham TV +AIT Al Jazeera English +ALL ACTION All Reality WE tv All Weddings WE tv Allblk Altice USA News Altitude Sports and Entertainment Always Funny Videos +AMC America TeVe America's Test Kitchen America's Voice @@ -51,6 +46,7 @@ American Heroes Channel American Pickers by History American Voices America’s Auction Channel +AMGA TV Anger Management Animal Planet Animax @@ -64,29 +60,17 @@ Are We There Yet Arirang TV Arizona Capitol Television Arizona NewsChannel +ART +ARTN Aspire AspireTV Life At Questo Mondo At the Movies Atreseries +AWE Ax Men +AXS TV Azteca América -BBC America -BBC Drama -BBC Earth -BBC Food -BBC Home & Garden -BBC News -BBC World News -BET -BET Gospel -BET Her -BET Hip-Hop -BET Jams -BET Pluto TV -BET Soul -BUZZR -BYU TV BabyFirst BabyTV Backstage @@ -111,19 +95,35 @@ Bandamax Barney and Friends Bay News 9 Baywatch -BeIN Sports +BBC America +BBC Drama +BBC Earth +BBC Food +BBC Home & Garden +BBC News +BBC World News Beauty Channel +BeIN Sports +beIN Sports en Espanol +beIN Sports en Español Bein Sports Xtra Believer’s Voice of Victory Network Best of Pawn Stars by History +BET +BET Gospel +BET Her +BET Hip-Hop +BET Jams +BET Pluto TV +BET Soul Big Ten Network Big Ten Network (BTN) Billiard TV Black News Channel BlackPix Bloomberg Originals -Bloomberg TV+ UHD Bloomberg Television +Bloomberg TV+ UHD Bob the Builder Bon Appetit Bonjour America @@ -136,34 +136,14 @@ Bravo Bravo Vault Bring It! BritBox Mysteries +BUZZR Buzzr +BYU TV C-SPAN C-SPAN 2 C-SPAN 3 C-SPAN2 C-SPAN3 -CBC News -CBS -CBS News -CBS Sports Network -CBeebies -CGTN -CGTN-America -CINEVAULT -CINEVAULT: Classics -CINEVAULT: Westerns -CMT -CMT Music -CNBC -CNBC World -CNN -CNN International -CNN en Espanol -CNN en Español -CONtv -CTGN -CTN -CTNi Cable 11 Northern Lancaster County Caillou Canal 22 Internacional @@ -176,8 +156,15 @@ Cartoon Network/Adult Swim Catchy Comedy Catholic Faith Network CatholicTV +CBC News +CBeebies +CBS +CBS News +CBS Sports Network Centroamerica TV Centroamérica TV +CGTN +CGTN-America Charge! Cheaters Cheddar @@ -186,12 +173,22 @@ China Global Network China Global Network Espanol Christian Television Network Cine Estelar +Cine Life Cine Mexicano Cine Nostalgia Cine Sony Television Cinelatino Cinemax +Cinemax 5Starmax +Cinemax Action +Cinemax Actionmax +Cinemax Classics +Cinemax Hits +Cinemax Moremax Cinemáx +CINEVAULT +CINEVAULT: Classics +CINEVAULT: Westerns Cinémoi Circle Circle Country @@ -199,12 +196,21 @@ City Classic Arts Showcase Classic Cinema Cleo TV +CMT +CMT Music +CNBC +CNBC World +CNN +CNN en Espanol +CNN en Español +CNN International Cold Case Files Comedy Central Comedy Central Pluto TV Comedy Dynamics Comedy.TV Comet +CONtv Cooking Channel Cosmos Court TV @@ -219,10 +225,11 @@ Crime 24/7 Crime Cults Killers Crime Thriller Crime Zone +CTGN +CTN +CTNi CubaMAX CubaPlay+ -DFH Network -DUST Sci-Fi Dabl Dance Moms Dare to Dream Network @@ -238,36 +245,29 @@ Deal or No Deal Degrassi Destination America Deutsche Welle +DFH Network Discovery Channel +Discovery en Espanol +Discovery en Español Discovery Familia Discovery Family Discovery Life -Discovery en Espanol -Discovery en Español Disney Channel Disney Junior Disney XD Divorce Court Doctor Who Classic -Dog TV Dog the Bounty Hunter +Dog TV +Dove Dove Channel Dr. G: Medical Examiner Drama Life Duck Dynasty +Dust +DUST Sci-Fi E! E! Keeping Up -ES.TV -ESPN -ESPN College Extra -ESPN Deportes -ESPN2 -ESPNU -ESPNews -ET Live -EWTN -EWTN Espanol -EWTN Español EarthX TV Ebony TV by Lionsgate Ecuador TV @@ -281,17 +281,22 @@ Epix Epix 2 Epix Drive-in Epix Hits +ES.TV +eScapes Esperanza TV +ESPN +ESPN College Extra +ESPN Deportes +ESPN2 +ESPNews +ESPNU Estrella News Estrella TV +ET Live Euronews English -FETV -FM -FOROtv -FX -FX Movie Channel -FXX -FYI +EWTN +EWTN Espanol +EWTN Español FailArmy Family Affair Channel Family Entertainment Television @@ -300,6 +305,7 @@ Family Movie Classics Fandango at Home Fawesome Fear Factor +FETV FilmRise FilmRise Anime FilmRise Black TV @@ -309,10 +315,12 @@ FilmRise Sci-Fi FilmRise Western Flipping Nation by A&E Flix +FM Folk TV Food Network Food52 Forensic Files +FOROtv Fox Fox Business Fox Business Network @@ -331,29 +339,33 @@ Funny or Die Fuse Fuse Backstage Fuse Beat +FX +FX Movie Channel +FXM +FXX +FYI GAC Family GAC Living -GEB America -GMA Network -GOD TV -GOL TV -GSN Galavision Galavisión Game Show A Go-Go Game Show Central Game Show Network Gardening with Monty Don +GEB America Gem Shopping Network German Kino Plus Get TV GetTV GlewedTV Sci-Fi Global Got Talent +GMA Network +GOD TV +God’s Learning Channel +GOL TV +Golf Channel GoTraveler GoUSA TV -God’s Learning Channel -Golf Channel Gran Cine Gravitas Movies Great American Adventures @@ -361,31 +373,30 @@ Great American Faith & Living Great American Family Grit Grit Xtra +GSN Gusto TV -HBO -HBO Comedy -HBO Family -HBO Latino -HBO Signature -HBO Zone -HBO2 -HDNet Movies -HGTV -HITN -HLN -HSN -HSN2 -HTV Hallmark Channel Hallmark Drama Hallmark Movies & More Hallmark Movies & Mysteries HappyKids Haystack News +HBO +HBO Comedy +HBO Drama +HBO Family +HBO Hits +HBO Latino +HBO Movies +HBO Signature +HBO Zone +HBO2 +HDNet Movies Heartland Hell's Kitchen Her Free Movies Heroes & Icons +HGTV Hi-YAH! High Vision Highway through Hell @@ -393,6 +404,8 @@ Highway to Heaven History History en Espanol History en Español +HITN +HLN Hoarders by A&E Hogar de HGTV Hola! TV @@ -402,51 +415,52 @@ Hope Channel Horizon TV Horror by ALTER Hot Ones +HSN +HSN2 +HTV Hungry I Survived… -IFC -INSP -ION Mystery -ION Plus -ION Television -ITV Gold +i24NEWS Ice Road Truckers Idaho's Very Own 24/7 +IFC Impact Television In Depth Graham Bensinger In the Garage IndiePlex +INSP Intervention by A&E Investigation Discovery +ION Mystery +ION Plus +ION Television Ion Television -JTV -JTV Jewelry Love -JUS One -JUS Punjabi +ITV Gold Jadeworld Jamie Oliver Jewelry Television Jewish Broadcasting Service Jewish Life Television Journy +JTV +JTV Jewelry Love Judge Nosey +JUS One +JUS Punjabi Just for Laughs Gags Justice Central -KO-AM TV Kanopy Kartoon Channel! Kevin Hart's LOL Network Kitchen Nightmares +KO-AM TV Kriminal Presented by A&E -LATV -LEGO Channel -LMN -LOL! Network -LRW Laff Lassie +LATV Law & Crime Law & Order +LEGO Channel LeSEA Lidia's Kitchen Lifetime @@ -459,24 +473,66 @@ Little House on the Prairie Little Women: LA Live Well Network LiveNOW from FOX +LMN Local Now Localish Logo TV +LOL! Network Longhorn Network Love & Hip Hop Love Nature Love Quest: Powered by Banijay Loveworld USA +LRW Luxe.tv Lx News +Made in Chelsea +Magnolia Network +Marquee Sports Network +Matched Married Meet +Maverick Black Cinema +MavTV MAVTV +Max +Mega TV +MeTV +MeTV Toons +MeTV+ +Mexicanal +MGM MGM HD +MGM+ +MGM+ Drive In +MGM+ East +MGM+ Hits +MGM+ Marquee MHz Now +Mid-Atlantic Sports Network +Midnight Pulp +Midsomer Murders +Military History +Million Dollar Dream Home +Million Dollar Listing Vault +Mixible MKTV MLB Extra Innings MLB Network MLB Strike Zone MLS Direct Kick +Modern Marvels +Moonbug +MoreMax +Motor Trend +MotorTrend FAST TV +Movie Favorites by Lifetime +Movie Hub +Movie Hub Action +Movie Hub West +MovieMax +MoviePlex +MovieSphere +MovieSphere by Lionsgate +MrBeast MSG MSG 2 MSG Sportsnet @@ -493,48 +549,20 @@ MTV Pluto TV MTV Tres MTV2 MTVU -Made in Chelsea -Magnolia Network -Marquee Sports Network -Matched Married Meet -MavTV -Maverick Black Cinema -MeTV -MeTV Toons -MeTV+ -Mega TV -Mexicanal -Mid-Atlantic Sports Network -Midnight Pulp -Midsomer Murders -Military History -Million Dollar Dream Home -Million Dollar Listing Vault -Mixible -Modern Marvels -Moonbug -MoreMax -Motor Trend -MotorTrend FAST TV -Movie Favorites by Lifetime -Movie Hub -Movie Hub Action -Movie Hub West -MovieMax -MoviePlex -MovieSphere -MovieSphere by Lionsgate -MrBeast Multimedios Television Multimedios Televisión Murder, She Wrote MyDestination.TV MyNetworkTV -MyTime Movie Network Mystery Science Theater 3000 +MyTime Movie Network Myx TV N+ Foro NASA TV +Nashville +Nat Geo Mundo +Nat Geo Wild +National Geographic NBA League Pass NBA TV NBC @@ -545,22 +573,6 @@ NBC Sports California NBC Sports Chicago NBC Sports Philadelphia NBC Sports Washington -NFL Network -NFL RedZone -NFL Sunday Ticket -NFL Sunday Ticket Fantasy Zone -NFL Sunday Ticket Multiview Fan -NFL Sunday Ticket RedZone -NHK World Japan -NHL Center Ice -NHL Network -NRB Network -NY1 -NYCTV -Nashville -Nat Geo Mundo -Nat Geo Wild -National Geographic New England Cable News New England Sports Network New Greek TV @@ -578,18 +590,30 @@ News 13 News 9 Now News Channel Nebraska News on 6 Now +Newsmax TV NewsNation NewsWatch 15 -Newsmax TV Newsy +NFL Network +NFL RedZone +NFL Sunday Ticket +NFL Sunday Ticket Fantasy Zone +NFL Sunday Ticket Multiview Fan +NFL Sunday Ticket RedZone +NHK World Japan +NHL Center Ice +NHL Network Nick Jr. Nick Pluto TV -NickMusic Nickelodeon +NickMusic Nicktoons Nikita Nosey +NRB Network Nuestra Tele +NY1 +NYCTV OAN Plus Oc 16 Olympic Channel @@ -604,12 +628,6 @@ Outside TV Ovation Oxygen Oxygen True Crimes Archive -PBS -PBS Antiques Roadshow -PBS Food -PBS Kids -PFL MMA -PXTV Pac-12 Network PanArmenian TV Paramount Movie Channel @@ -617,9 +635,14 @@ Paramount Network Paranormal File Pasiones Pattrn +PBS +PBS Antiques Roadshow +PBS Food +PBS Kids PeopleTV Perform Pets.TV +PFL MMA Pittsburgh Cable News Channel PixL Playboy TV en Espanol @@ -646,37 +669,35 @@ Power Rangers Primo TV Pursuit Channel Pursuit Up +PXTV QVC QVC2 QVC3 RCN Nuestra Tele -REELZ Famous & Infamous -RFD-TV -RSC Real America's Voice Real America’s Voice Real Disaster Channel Real Housewives Vault Recipe.TV Reelz +REELZ Famous & Infamous +Retro Plex Retro TV RetroCrush +RetroPlex Reuters Rev & Roll Revolt Revry Rewind TV +RFD-TV Rifftrax Ritmoson Latino Road Renegades Roku Fireside Roku Spooktacular Root Sports Northwest -SEC Network -SHOxBET -SLING Freestream -SNL Vault -SPT TV +RSC Saigon Broadcasting Television Network Samuel Goldwyn Classics Samuel Goldwyn Films @@ -684,9 +705,14 @@ Science Channel Scientology Network ScreenPix ScreenPix Action +ScreenPix Action US +ScreenPix US ScreenPix Voices +ScreenPix Voices US ScreenPix Westerns +ScreenPix Westerns US Scripps News +SEC Network Sensical Jr Shades of Black Shark Tank @@ -694,22 +720,28 @@ Shaun the Sheep Shop LC ShopHQ ShopLC +Shorts TV ShortsHD Shout! Factory TV Shout! TV Showtime Showtime 2 +Showtime BET Showtime Extreme Showtime Family Zone +Showtime HD East Showtime Next Showtime Showcase Showtime Women +SHOxBET Sino TV Sky Link TV Sky News Slightly Off IFC +SLING Freestream Smile Smithsonian Channel +SNL Vault Sonlife Broadcasting Network Sonlife Broadcasting Network (SBN) Sony Canal Comedias @@ -730,9 +762,10 @@ Spectrum News 1 Rochester Spectrum Sports Spectrum SportsNet Spectrum SportsNet LA +Sportsman Channel SportsNet New York SportsNet Pittsburgh -Sportsman Channel +SPT TV Stadium Stadium College Sports Atlantic Stadium College Sports Central @@ -752,8 +785,9 @@ Starz Encore Español Starz Encore Family Starz Encore Suspense Starz Encore Westerns -Starz Kids & Family Starz in Black +Starz Kids & Family +Starz Kids West Stories By AMC Story Television Sundance TV @@ -762,46 +796,28 @@ Supermarket Sweep Swamp People Sweet Escapes Syfy +Tai Seng Sat TV +Tapesh TV +Tastemade +Tastemade en Espanol +Tastemade Home +Tastemade Travel Channel TBN TBN Inspire TBS TCM +TCM Movies TED -TELE N -TLC -TNT -TRN -TUDN -TV 13 The Poconos -TV Asia -TV Chile -TV Japan -TV Land -TV Land Drama -TV One -TV One Crime & Justice -TV Venezuela -TV503 Crossing TV -TVE Internacional -TVG -TVG Network -TVG2 -TYT Network -Tai Seng Sat TV -Tapesh TV -Tastemade -Tastemade Home -Tastemade Travel Channel -Tastemade en Espanol TeenNick +TELE N +Telefe Internacional TeleFormula TeleFórmula TeleHit TeleHit Musica -TeleXitos -Telefe Internacional Telemundo Teletubbies +TeleXitos Tennis Channel The Africa Channel The Arabic Channel @@ -809,12 +825,12 @@ The Archive The Asylum The Biggest Loser The Bob Ross Channel -The CW The Carol Burnett Show The Comedy Shop The Comedy Store The Cowboy Channel The Curse of Oak Island +The CW The Design Network The Emeril Lagasse Channel The Filipino Channel @@ -847,6 +863,8 @@ This Old House This TV ThrillerMax Tiny House Nation +TLC +TNT Today All Day Top Chef Vault: Las Vegas Top Cine @@ -856,15 +874,29 @@ Transformers Travel + Adventure Travel Channel Tri-State Christian Television +TRN TruBlu -TruTV True Crime Network +TruTV +TUDN Turner Classic Movies +TV 13 The Poconos +TV Asia +TV Chile +TV Japan +TV Land +TV Land Drama +TV One +TV One Crime & Justice +TV Venezuela +TV503 Crossing TV +TVE Internacional +TVG +TVG Network +TVG2 +tvK TyC Sports -UP TV -USA Network -USA Today -USArmenia TV +TYT Network Ultra Banda Ultra Cine Ultra Clasico @@ -877,10 +909,9 @@ Ultra Luna Ultra Macho Ultra Mex Ultratainment -UnXplained Zone +Unidentified UniMas UniMás -Unidentified Universal Action Universal Kids Universal Monsters @@ -889,10 +920,15 @@ Univision Univision tlnovelas Unsolved Mysteries Untamed Sports TV +UnXplained Zone Up TV +UP TV +USA Network +USA Today +USArmenia TV V-me -VH1 VePlus +VH1 Vice Viceland Vien Thao TV @@ -902,36 +938,31 @@ Vix Cine Club Vix Novelas De Oro Vix Novelas en Familia Vogue -WE tv -WGN America -WIRED -WJLA 24/7 News Wanted: Dead or Alive Watchlist Waypoint TV +WE tv We TV WeatherNation WeatherNation TV WeatherSpy Welcome Home +WGN America Whistle TV Wild'N Out Wipeout Xtra +WIRED +WJLA 24/7 News Women's Sports Network World Fishing Network World's Most Evil Killers Wu Tang Collection TV XITE Xplore -YES Network Yahoo Finance +YES Network Yo! MTV Z Living Zee TV ZooMoo -beIN Sports en Espanol -beIN Sports en Español -eScapes -i24NEWS -tvK ¡Sorpresa! \ No newline at end of file diff --git a/Stream-Mapparr/plugin.py b/Stream-Mapparr/plugin.py index 1d36a58..2d01ad6 100644 --- a/Stream-Mapparr/plugin.py +++ b/Stream-Mapparr/plugin.py @@ -16,7 +16,7 @@ from django.utils import timezone from apps.channels.models import Channel, Stream, ChannelStream, ChannelProfileMembership # Setup logging using Dispatcharr's format -LOGGER = logging.getLogger("plugins.channel_addarr") +LOGGER = logging.getLogger("plugins.stream_mapparr") if not LOGGER.handlers: handler = logging.StreamHandler() formatter = logging.Formatter("%(levelname)s %(name)s %(message)s") @@ -28,7 +28,7 @@ class Plugin: """Dispatcharr Stream-Mapparr Plugin""" name = "Stream-Mapparr" - version = "0.1" + version = "0.2" description = "Automatically add matching streams to channels based on name similarity and quality precedence" # Settings rendered by UI @@ -60,7 +60,7 @@ class Plugin: "type": "string", "default": "", "placeholder": "Sports", - "help_text": "The name of an existing Channel Profile to process channels from.", + "help_text": "*** Required Field *** - The name of an existing Channel Profile to process channels from.", }, { "id": "selected_groups", @@ -78,6 +78,13 @@ class Plugin: "placeholder": "4K, [4K], [Dead]", "help_text": "Tags to ignore when matching streams. Space-separated in channel names unless they contain brackets/parentheses.", }, + { + "id": "visible_channel_limit", + "label": "Visible Channel Limit", + "type": "number", + "default": 1, + "help_text": "Number of channels that will be visible and have streams added. Channels are prioritized by quality tags, then by channel number.", + }, ] # Actions for Dispatcharr UI @@ -102,19 +109,34 @@ class Plugin: "message": "This will replace existing stream assignments on matching channels. Continue?" } }, + { + "id": "manage_channel_visibility", + "label": "Manage Channel Visibility", + "description": "Disable all channels, then enable only channels with 0 or 1 stream (excluding channels attached to others)", + "confirm": { + "required": True, + "title": "Manage Channel Visibility?", + "message": "This will disable ALL channels in the profile, then enable only channels with 0 or 1 stream that are not attached to other channels. Continue?" + } + }, ] - # Quality precedence order - QUALITY_ORDER = [ - "4K", "[4K]", "(4K)", - "FHD", "[FHD]", "(FHD)", - "HD", "[HD]", "(HD)", - "SD", "[SD]", "(SD)", + # Quality precedence order for channel tags + CHANNEL_QUALITY_TAG_ORDER = ["[4K]", "[FHD]", "[HD]", "[SD]", "[Unknown]", "[Slow]", ""] + + # Quality precedence order for stream tags (brackets and parentheses) + STREAM_QUALITY_ORDER = [ + "[4K]", "(4K)", "4K", + "[FHD]", "(FHD)", "FHD", + "[HD]", "(HD)", "HD", "(H)", + "[SD]", "(SD)", "SD", + "(F)", + "(D)", "Slow", "[Slow]", "(Slow)" ] def __init__(self): - self.processed_data_file = "/data/channel_addarr_processed.json" + self.processed_data_file = "/data/stream_mapparr_processed.json" self.loaded_channels = [] self.loaded_streams = [] self.channel_stream_matches = [] @@ -301,29 +323,68 @@ class Plugin: def _extract_quality(self, stream_name): """Extract quality indicator from stream name.""" - for quality in self.QUALITY_ORDER: + for quality in self.STREAM_QUALITY_ORDER: # Match quality with or without brackets/parentheses - quality_clean = quality.strip('[]()').strip() - patterns = [ - f"\\[{quality_clean}\\]", - f"\\({quality_clean}\\)", - f"\\b{quality_clean}\\b" - ] - for pattern in patterns: - if re.search(pattern, stream_name, re.IGNORECASE): + if quality in ["(H)", "(F)", "(D)"]: + # Special handling for single-letter quality indicators + if quality in stream_name: return quality + else: + quality_clean = quality.strip('[]()').strip() + patterns = [ + f"\\[{quality_clean}\\]", + f"\\({quality_clean}\\)", + f"\\b{quality_clean}\\b" + ] + for pattern in patterns: + if re.search(pattern, stream_name, re.IGNORECASE): + return quality return None + def _extract_channel_quality_tag(self, channel_name): + """Extract quality tag from channel name for prioritization.""" + for tag in self.CHANNEL_QUALITY_TAG_ORDER: + if tag == "": + # Check if channel has no quality tag + has_tag = False + for check_tag in self.CHANNEL_QUALITY_TAG_ORDER[:-1]: # Exclude blank + if check_tag in channel_name: + has_tag = True + break + if not has_tag: + return "" + elif tag in channel_name: + return tag + return "" + + def _sort_channels_by_priority(self, channels): + """Sort channels by quality tag priority, then by channel number.""" + def get_priority_key(channel): + quality_tag = self._extract_channel_quality_tag(channel['name']) + try: + quality_index = self.CHANNEL_QUALITY_TAG_ORDER.index(quality_tag) + except ValueError: + quality_index = len(self.CHANNEL_QUALITY_TAG_ORDER) + + # Get channel number, default to 999999 if not available + channel_number = channel.get('channel_number', 999999) + if channel_number is None: + channel_number = 999999 + + return (quality_index, channel_number) + + return sorted(channels, key=get_priority_key) + def _sort_streams_by_quality(self, streams): """Sort streams by quality precedence.""" def get_quality_index(stream): quality = self._extract_quality(stream['name']) if quality: try: - return self.QUALITY_ORDER.index(quality) + return self.STREAM_QUALITY_ORDER.index(quality) except ValueError: - return len(self.QUALITY_ORDER) - return len(self.QUALITY_ORDER) + return len(self.STREAM_QUALITY_ORDER) + return len(self.STREAM_QUALITY_ORDER) return sorted(streams, key=get_quality_index) @@ -347,6 +408,46 @@ class Plugin: return channel_names + def _is_ota_channel(self, channel_name): + """Check if a channel name matches OTA pattern.""" + # OTA pattern: "NETWORK - STATE City (CALLSIGN)" with optional quality tags + # Examples: "ABC - TN Chattanooga (WTVC)", "NBC - NY New York (WNBC) [HD]" + ota_pattern = r'^[A-Z]+\s*-\s*[A-Z]{2}\s+.+\([A-Z]+.*?\)' + return bool(re.search(ota_pattern, channel_name)) + + def _extract_ota_info(self, channel_name): + """Extract network, state, city, and callsign from OTA channel name.""" + # Pattern: "NETWORK - STATE City (CALLSIGN)" with optional quality tags after + # Match: CBS - IN South Bend (WSBT) [HD] + match = re.match(r'^([A-Z]+)\s*-\s*([A-Z]{2})\s+([^(]+)\(([A-Z][A-Z0-9-]+)\)', channel_name) + if match: + network = match.group(1).strip().upper() + state = match.group(2).strip().upper() + city = match.group(3).strip().upper() + callsign = match.group(4).strip().upper() + + # Clean callsign (remove anything after dash) + callsign = self._parse_callsign(callsign) + + return { + 'network': network, + 'state': state, + 'city': city, + 'callsign': callsign + } + return None + + def _parse_callsign(self, callsign): + """Extract clean callsign, removing suffixes after dash.""" + if not callsign: + return None + + # Remove anything after dash (e.g., "WLNE-TV" becomes "WLNE") + if '-' in callsign: + callsign = callsign.split('-')[0].strip() + + return callsign.upper() + def _match_streams_to_channel(self, channel, all_streams, logger, ignore_tags=None, known_channels=None, networks_data=None): """Find matching streams for a channel based on name similarity.""" if ignore_tags is None: @@ -358,22 +459,49 @@ class Plugin: channel_name = channel['name'] - # Check if this is an OTA channel + # FIRST: Check if this is an OTA channel and try callsign matching if self._is_ota_channel(channel_name): logger.info(f"Matching OTA channel: {channel_name}") - matched_streams = self._match_ota_streams(channel_name, all_streams, networks_data, logger) - if matched_streams: - sorted_streams = self._sort_streams_by_quality(matched_streams) - logger.info(f" Sorted {len(sorted_streams)} OTA streams by quality") - return sorted_streams + # Extract callsign from channel name BEFORE cleaning + ota_info = self._extract_ota_info(channel_name) + if ota_info: + callsign = ota_info['callsign'] + logger.info(f" Extracted callsign: {callsign}") + + # Search for streams containing the callsign + matching_streams = [] + callsign_pattern = r'\b' + re.escape(callsign) + r'\b' + + for stream in all_streams: + stream_name = stream['name'] + + # Check if stream contains the callsign + if re.search(callsign_pattern, stream_name, re.IGNORECASE): + matching_streams.append(stream) + logger.info(f" Found callsign match: {stream_name}") + + if matching_streams: + sorted_streams = self._sort_streams_by_quality(matching_streams) + logger.info(f" Sorted {len(sorted_streams)} streams by quality (callsign matching)") + return sorted_streams + else: + logger.info(f" No streams found with callsign {callsign}") + # Try networks.json fallback + logger.info(f" Trying networks.json OTA matching as fallback") + matched_streams = self._match_ota_streams(channel_name, all_streams, networks_data, logger) + + if matched_streams: + sorted_streams = self._sort_streams_by_quality(matched_streams) + logger.info(f" Sorted {len(sorted_streams)} OTA streams by quality") + return sorted_streams else: - logger.info(f" No OTA streams found") - return [] + logger.info(f" Could not extract OTA info from: {channel_name}") - # Regular channel matching logic for non-OTA channels + # SECOND: Regular channel matching logic (only if not OTA or OTA matching failed) cleaned_channel_name = self._clean_channel_name(channel_name, ignore_tags) - logger.info(f"Matching streams for channel: {channel_name} (cleaned: {cleaned_channel_name})") + logger.info(f"Matching streams for channel: {channel_name}") + logger.info(f" Cleaned channel name: {cleaned_channel_name}") # Debug: show first few stream names to verify we have streams if all_streams: @@ -396,16 +524,26 @@ class Plugin: if cleaned_channel_name.lower() in known_channel.lower(): longer_channels.append(known_channel) - logger.info(f" Longer channels to exclude: {longer_channels}") + if longer_channels: + logger.info(f" Longer channels to exclude: {longer_channels}") for stream in all_streams: stream_name = stream['name'] + stream_cleaned = self._clean_channel_name(stream_name, ignore_tags) - # Check if stream matches our channel name (case insensitive) - pattern = r'(?:^|[:\s])\s*' + escaped_channel_name + r'(?:\s*[\(\|]|$|\s+[\(\|]?$)' + # Simple exact match after cleaning (case insensitive) + if cleaned_channel_name.lower() == stream_cleaned.lower(): + logger.info(f" Found exact match: {stream_name}") + matching_streams.append(stream) + continue + + # Pattern matching - more flexible + # The pattern looks for the cleaned channel name as a substring + # It can be at the start, after a colon/space, or standalone + pattern = r'(?:^|[:\s])\s*' + escaped_channel_name + r'(?:\s*[\[\(\|]|$)' if re.search(pattern, stream_name, re.IGNORECASE): - # Filter 1: Skip if it's part of a call sign + # Filter 1: Skip if it is part of a call sign call_sign_pattern = r'\b[A-Z]{4,5}\s+' + escaped_channel_name + r'\b' if re.search(call_sign_pattern, stream_name): logger.info(f" Skipped (call sign): {stream_name}") @@ -415,7 +553,7 @@ class Plugin: skip_stream = False for longer_channel in longer_channels: escaped_longer = re.escape(longer_channel) - longer_pattern = r'(?:^|[:\s])\s*' + escaped_longer + r'(?:\s*[\(\|]|$|\s+[\(\|]?$)' + longer_pattern = r'(?:^|[:\s])\s*' + escaped_longer + r'(?:\s*[\[\(\|]|$)' if re.search(longer_pattern, stream_name, re.IGNORECASE): logger.info(f" Skipped (matches longer channel '{longer_channel}'): {stream_name}") skip_stream = True @@ -433,17 +571,111 @@ class Plugin: continue matching_streams.append(stream) - logger.info(f" Found match: {stream_name}") + logger.info(f" Found pattern match: {stream_name}") + # Return all matching streams sorted by quality if matching_streams: - # Sort by quality precedence sorted_streams = self._sort_streams_by_quality(matching_streams) - logger.info(f" Sorted {len(sorted_streams)} streams by quality") + logger.info(f" Sorted {len(sorted_streams)} streams by quality (regular matching)") return sorted_streams logger.info(f" No matching streams found") return [] + def _load_networks_data(self, logger): + """Load OTA network data from networks.json file.""" + networks_file = os.path.join(os.path.dirname(__file__), 'networks.json') + networks_data = [] + + try: + if os.path.exists(networks_file): + with open(networks_file, 'r', encoding='utf-8') as f: + networks_data = json.load(f) + logger.info(f"Loaded {len(networks_data)} network entries from networks.json") + else: + logger.warning(f"networks.json not found at {networks_file}") + except Exception as e: + logger.error(f"Error loading networks.json: {e}") + + return networks_data + + def _parse_network_affiliation(self, network_affiliation): + """Extract the primary network from network_affiliation field.""" + if not network_affiliation: + return None + + # Handle patterns like "CBS (12.1), CW (12.2), MeTV (12.3)" + # Extract text before the first parenthesis + if '(' in network_affiliation: + network_affiliation = network_affiliation.split('(')[0].strip() + + # Handle patterns like "WTOV D1 - NBC; WTOV D2 - FOX" + # Extract text after the last dash before the first separator + if ' - ' in network_affiliation: + # Split by semicolons/slashes first to get the first segment + for separator in [';', '/']: + if separator in network_affiliation: + network_affiliation = network_affiliation.split(separator)[0].strip() + break + # Now extract after the dash + if ' - ' in network_affiliation: + network_affiliation = network_affiliation.split(' - ')[-1].strip() + + # Split by separators: comma, semicolon, or slash + for separator in [',', ';', '/']: + if separator in network_affiliation: + network_affiliation = network_affiliation.split(separator)[0].strip() + break + + return network_affiliation.upper() + + def _match_ota_streams(self, channel_name, all_streams, networks_data, logger): + """Match OTA channel to streams using networks.json data.""" + ota_info = self._extract_ota_info(channel_name) + if not ota_info: + logger.info(f" Could not parse OTA info from: {channel_name}") + return [] + + logger.info(f" OTA channel parsed: Network={ota_info['network']}, State={ota_info['state']}, City={ota_info['city']}, Callsign={ota_info['callsign']}") + + # Find matching network entries + matching_networks = [] + for network_entry in networks_data: + entry_callsign = self._parse_callsign(network_entry.get('callsign', '')) + entry_network = self._parse_network_affiliation(network_entry.get('network_affiliation', '')) + entry_state = network_entry.get('community_served_state', '').upper() + + # Match on callsign and state + if (entry_callsign == ota_info['callsign'] and + entry_state == ota_info['state'] and + entry_network == ota_info['network']): + matching_networks.append(network_entry) + logger.info(f" Found matching network entry: {entry_callsign} in {entry_state}") + + if not matching_networks: + logger.info(f" No matching network entries found in networks.json") + return [] + + # Now search for streams matching the exact callsign + matching_streams = [] + callsign = ota_info['callsign'] + + for stream in all_streams: + stream_name = stream['name'].upper() + + # Use word boundary regex to ensure exact callsign match + # Pattern: callsign must be a complete word (not part of a longer callsign) + callsign_pattern = r'\b' + re.escape(callsign) + r'\b' + + if re.search(callsign_pattern, stream_name): + # Additional validation: ensure it is the right network + # The stream should contain the network name or be validated by context + if ota_info['network'] in stream_name: + matching_streams.append(stream) + logger.info(f" Found OTA stream match: {stream['name']}") + + return matching_streams + def run(self, action, params, context): """Main plugin entry point""" LOGGER.info(f"Stream-Mapparr run called with action: {action}") @@ -456,6 +688,7 @@ class Plugin: "load_process_channels": self.load_process_channels_action, "preview_changes": self.preview_changes_action, "add_streams_to_channels": self.add_streams_to_channels_action, + "manage_channel_visibility": self.manage_channel_visibility_action, } if action not in action_map: @@ -482,10 +715,14 @@ class Plugin: profile_name = settings.get("profile_name", "").strip() selected_groups_str = settings.get("selected_groups", "").strip() ignore_tags_str = settings.get("ignore_tags", "").strip() + visible_channel_limit = int(settings.get("visible_channel_limit", 1)) if not profile_name: return {"status": "error", "message": "Profile Name must be configured in the plugin settings."} + if visible_channel_limit < 1: + return {"status": "error", "message": "Visible Channel Limit must be at least 1."} + # Parse ignore tags ignore_tags = [] if ignore_tags_str: @@ -635,7 +872,7 @@ class Plugin: sample_stream_names = [s.get('name', 'N/A') for s in all_streams_data[:10]] logger.info(f"Sample stream names: {sample_stream_names}") - # Store loaded data including ignore tags + # Store loaded data including ignore tags and visible channel limit self.loaded_channels = channels_to_process self.loaded_streams = all_streams_data @@ -646,6 +883,7 @@ class Plugin: "profile_id": profile_id, "selected_groups": selected_groups, "ignore_tags": ignore_tags, + "visible_channel_limit": visible_channel_limit, "channels": channels_to_process, "streams": all_streams_data } @@ -657,146 +895,13 @@ class Plugin: return { "status": "success", - "message": f"Successfully loaded {len(channels_to_process)} channels from profile '{profile_name}'{group_filter_info}\n\nFound {len(all_streams_data)} streams available for matching.\n\nYou can now run 'Preview Changes' or 'Add Streams to Channels'." + "message": f"Successfully loaded {len(channels_to_process)} channels from profile '{profile_name}'{group_filter_info}\n\nFound {len(all_streams_data)} streams available for matching.\n\nVisible channel limit set to: {visible_channel_limit}\n\nYou can now run 'Preview Changes' or 'Add Streams to Channels'." } except Exception as e: logger.error(f"Error loading channels: {str(e)}") return {"status": "error", "message": f"Error loading channels: {str(e)}"} - def _load_networks_data(self, logger): - """Load OTA network data from networks.json file.""" - networks_file = os.path.join(os.path.dirname(__file__), 'networks.json') - networks_data = [] - - try: - if os.path.exists(networks_file): - with open(networks_file, 'r', encoding='utf-8') as f: - networks_data = json.load(f) - logger.info(f"Loaded {len(networks_data)} network entries from networks.json") - else: - logger.warning(f"networks.json not found at {networks_file}") - except Exception as e: - logger.error(f"Error loading networks.json: {e}") - - return networks_data - - def _parse_network_affiliation(self, network_affiliation): - """Extract the primary network from network_affiliation field.""" - if not network_affiliation: - return None - - # Handle patterns like "CBS (12.1), CW (12.2), MeTV (12.3)" - # Extract text before the first parenthesis - if '(' in network_affiliation: - network_affiliation = network_affiliation.split('(')[0].strip() - - # Handle patterns like "WTOV D1 - NBC; WTOV D2 - FOX" - # Extract text after the last dash before the first separator - if ' - ' in network_affiliation: - # Split by semicolons/slashes first to get the first segment - for separator in [';', '/']: - if separator in network_affiliation: - network_affiliation = network_affiliation.split(separator)[0].strip() - break - # Now extract after the dash - if ' - ' in network_affiliation: - network_affiliation = network_affiliation.split(' - ')[-1].strip() - - # Split by separators: comma, semicolon, or slash - for separator in [',', ';', '/']: - if separator in network_affiliation: - network_affiliation = network_affiliation.split(separator)[0].strip() - break - - return network_affiliation.upper() - - def _parse_callsign(self, callsign): - """Extract clean callsign, removing suffixes after dash.""" - if not callsign: - return None - - # Remove anything after dash (e.g., "WLNE-TV" becomes "WLNE") - if '-' in callsign: - callsign = callsign.split('-')[0].strip() - - return callsign.upper() - - def _is_ota_channel(self, channel_name): - """Check if a channel name matches OTA pattern.""" - # OTA pattern: "NETWORK - STATE City (CALLSIGN)" - # Examples: "ABC - TN Chattanooga (WTVC)", "NBC - NY New York (WNBC)" - ota_pattern = r'^[A-Z]+\s*-\s*[A-Z]{2}\s+.+\([A-Z]+.*\)$' - return bool(re.match(ota_pattern, channel_name)) - - def _extract_ota_info(self, channel_name): - """Extract network, state, city, and callsign from OTA channel name.""" - # Pattern: "NETWORK - STATE City (CALLSIGN)" - match = re.match(r'^([A-Z]+)\s*-\s*([A-Z]{2})\s+(.+)\(([A-Z]+[^)]*)\)$', channel_name) - if match: - network = match.group(1).strip().upper() - state = match.group(2).strip().upper() - city = match.group(3).strip().upper() - callsign = match.group(4).strip().upper() - - # Clean callsign (remove anything after dash) - callsign = self._parse_callsign(callsign) - - return { - 'network': network, - 'state': state, - 'city': city, - 'callsign': callsign - } - return None - - def _match_ota_streams(self, channel_name, all_streams, networks_data, logger): - """Match OTA channel to streams using networks.json data.""" - ota_info = self._extract_ota_info(channel_name) - if not ota_info: - logger.info(f" Could not parse OTA info from: {channel_name}") - return [] - - logger.info(f" OTA channel parsed: Network={ota_info['network']}, State={ota_info['state']}, City={ota_info['city']}, Callsign={ota_info['callsign']}") - - # Find matching network entries - matching_networks = [] - for network_entry in networks_data: - entry_callsign = self._parse_callsign(network_entry.get('callsign', '')) - entry_network = self._parse_network_affiliation(network_entry.get('network_affiliation', '')) - entry_state = network_entry.get('community_served_state', '').upper() - - # Match on callsign and state - if (entry_callsign == ota_info['callsign'] and - entry_state == ota_info['state'] and - entry_network == ota_info['network']): - matching_networks.append(network_entry) - logger.info(f" Found matching network entry: {entry_callsign} in {entry_state}") - - if not matching_networks: - logger.info(f" No matching network entries found in networks.json") - return [] - - # Now search for streams matching the exact callsign - matching_streams = [] - callsign = ota_info['callsign'] - - for stream in all_streams: - stream_name = stream['name'].upper() - - # Use word boundary regex to ensure exact callsign match - # Pattern: callsign must be a complete word (not part of a longer callsign) - callsign_pattern = r'\b' + re.escape(callsign) + r'\b' - - if re.search(callsign_pattern, stream_name): - # Additional validation: ensure it's the right network - # The stream should contain the network name or be validated by context - if ota_info['network'] in stream_name: - matching_streams.append(stream) - logger.info(f" Found OTA stream match: {stream['name']}") - - return matching_streams - def preview_changes_action(self, settings, logger): """Preview which streams will be added to channels without making changes.""" if not os.path.exists(self.processed_data_file): @@ -816,41 +921,95 @@ class Plugin: channels = processed_data.get('channels', []) streams = processed_data.get('streams', []) + visible_channel_limit = processed_data.get('visible_channel_limit', 1) if not channels: return {"status": "error", "message": "No channels found in processed data."} logger.info(f"Previewing changes for {len(channels)} channels with {len(streams)} available streams") + logger.info(f"Visible channel limit: {visible_channel_limit}") - # Match streams to channels - matches = [] - channels_with_matches = 0 - channels_without_matches = 0 - + # Group channels by their cleaned name for matching + channel_groups = {} ignore_tags = processed_data.get('ignore_tags', []) - + for channel in channels: - matched_streams = self._match_streams_to_channel(channel, streams, logger, ignore_tags, known_channels, networks_data) - - match_info = { - "channel_id": channel['id'], - "channel_name": channel['name'], - "channel_number": channel.get('channel_number'), - "matched_streams": len(matched_streams), - "stream_names": [s['name'] for s in matched_streams], - "stream_ids": [s['id'] for s in matched_streams] - } - - matches.append(match_info) - - if matched_streams: - channels_with_matches += 1 + # Use ORIGINAL name for OTA channels, cleaned name for others + if self._is_ota_channel(channel['name']): + # For OTA channels, group by callsign + ota_info = self._extract_ota_info(channel['name']) + if ota_info: + group_key = f"OTA_{ota_info['callsign']}" + else: + group_key = self._clean_channel_name(channel['name'], ignore_tags) else: - channels_without_matches += 1 + group_key = self._clean_channel_name(channel['name'], ignore_tags) + + if group_key not in channel_groups: + channel_groups[group_key] = [] + channel_groups[group_key].append(channel) + + # Match streams to channel groups + all_matches = [] + total_channels_with_matches = 0 + total_channels_without_matches = 0 + total_channels_to_update = 0 + + for group_key, group_channels in channel_groups.items(): + logger.info(f"Processing channel group: {group_key} ({len(group_channels)} channels)") + + # Sort channels in this group by priority + sorted_channels = self._sort_channels_by_priority(group_channels) + + # Match streams for this channel group (using first channel as representative) + matched_streams = self._match_streams_to_channel( + sorted_channels[0], streams, logger, ignore_tags, known_channels, networks_data + ) + + # Determine which channels will be updated based on limit + channels_to_update = sorted_channels[:visible_channel_limit] + channels_not_updated = sorted_channels[visible_channel_limit:] + + # Add match info for channels that will be updated + for channel in channels_to_update: + match_info = { + "channel_id": channel['id'], + "channel_name": channel['name'], + "channel_number": channel.get('channel_number'), + "matched_streams": len(matched_streams), + "stream_names": [s['name'] for s in matched_streams], + "stream_ids": [s['id'] for s in matched_streams], + "will_update": True + } + all_matches.append(match_info) + + if matched_streams: + total_channels_with_matches += 1 + total_channels_to_update += 1 + else: + total_channels_without_matches += 1 + + # Add match info for channels that will NOT be updated + for channel in channels_not_updated: + match_info = { + "channel_id": channel['id'], + "channel_name": channel['name'], + "channel_number": channel.get('channel_number'), + "matched_streams": len(matched_streams), + "stream_names": [s['name'] for s in matched_streams], + "stream_ids": [s['id'] for s in matched_streams], + "will_update": False + } + all_matches.append(match_info) + + if matched_streams: + total_channels_with_matches += 1 + else: + total_channels_without_matches += 1 # Generate CSV timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"channel_addarr_preview_{timestamp}.csv" + filename = f"stream_mapparr_preview_{timestamp}.csv" filepath = os.path.join("/data/exports", filename) os.makedirs("/data/exports", exist_ok=True) @@ -862,19 +1021,21 @@ class Plugin: 'channel_number', 'matched_streams_count', 'stream_names', - 'stream_ids' + 'stream_ids', + 'will_update' ] writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() - for match in matches: + for match in all_matches: writer.writerow({ 'channel_id': match['channel_id'], 'channel_name': match['channel_name'], 'channel_number': match['channel_number'], 'matched_streams_count': match['matched_streams'], 'stream_names': ' | '.join(match['stream_names']), - 'stream_ids': ', '.join(map(str, match['stream_ids'])) + 'stream_ids': ', '.join(map(str, match['stream_ids'])), + 'will_update': 'Yes' if match['will_update'] else 'No' }) logger.info(f"Preview CSV exported to {filepath}") @@ -883,26 +1044,29 @@ class Plugin: message_parts = [ "Preview completed:", f"• Total channels: {len(channels)}", - f"• Channels with matches: {channels_with_matches}", - f"• Channels without matches: {channels_without_matches}", + f"• Channels with matches: {total_channels_with_matches}", + f"• Channels without matches: {total_channels_without_matches}", + f"• Channels that will be updated: {total_channels_to_update}", + f"• Visible channel limit: {visible_channel_limit}", "", f"Preview exported to: {filepath}", "", - "Sample matches:" + "Sample matches (✓ = will update):" ] # Show first 10 channels - for match in matches[:10]: + for match in all_matches[:10]: + update_marker = "✓" if match['will_update'] else "✗" if match['matched_streams'] > 0: stream_preview = ', '.join(match['stream_names'][:3]) if match['matched_streams'] > 3: stream_preview += f" ... (+{match['matched_streams'] - 3} more)" - message_parts.append(f"• {match['channel_name']}: {stream_preview}") + message_parts.append(f"{update_marker} {match['channel_name']}: {stream_preview}") else: - message_parts.append(f"• {match['channel_name']}: No matches found") + message_parts.append(f"{update_marker} {match['channel_name']}: No matches found") - if len(matches) > 10: - message_parts.append(f"... and {len(matches) - 10} more channels") + if len(all_matches) > 10: + message_parts.append(f"... and {len(all_matches) - 10} more channels") message_parts.append("") message_parts.append("Use 'Add Streams to Channels' to apply these changes.") @@ -940,58 +1104,178 @@ class Plugin: channels = processed_data.get('channels', []) streams = processed_data.get('streams', []) + visible_channel_limit = processed_data.get('visible_channel_limit', 1) + profile_id = processed_data.get('profile_id') if not channels: return {"status": "error", "message": "No channels found in processed data."} - logger.info(f"Adding streams to {len(channels)} channels") + if not profile_id: + return {"status": "error", "message": "Profile ID not found in processed data."} - # Match streams to channels and prepare updates - matches = [] + logger.info(f"Adding streams to channels with visible limit: {visible_channel_limit}") + + # Group channels by their cleaned name for matching + channel_groups = {} + ignore_tags = processed_data.get('ignore_tags', []) + + for channel in channels: + # Use ORIGINAL name for OTA channels, cleaned name for others + if self._is_ota_channel(channel['name']): + # For OTA channels, group by callsign + ota_info = self._extract_ota_info(channel['name']) + if ota_info: + group_key = f"OTA_{ota_info['callsign']}" + else: + group_key = self._clean_channel_name(channel['name'], ignore_tags) + else: + group_key = self._clean_channel_name(channel['name'], ignore_tags) + + if group_key not in channel_groups: + channel_groups[group_key] = [] + channel_groups[group_key].append(channel) + + # Match streams to channel groups and update + all_matches = [] channels_updated = 0 channels_skipped = 0 + channels_to_enable = [] # Track channels that should be enabled in the profile - ignore_tags = processed_data.get('ignore_tags', []) - - for channel in channels: - matched_streams = self._match_streams_to_channel(channel, streams, logger, ignore_tags, known_channels, networks_data) + for group_key, group_channels in channel_groups.items(): + logger.info(f"Processing channel group: {group_key} ({len(group_channels)} channels)") - match_info = { - "channel_id": channel['id'], - "channel_name": channel['name'], - "channel_number": channel.get('channel_number'), - "matched_streams": len(matched_streams), - "stream_names": [s['name'] for s in matched_streams], - "stream_ids": [s['id'] for s in matched_streams] - } + # Sort channels in this group by priority + sorted_channels = self._sort_channels_by_priority(group_channels) - matches.append(match_info) + # Match streams for this channel group (using first channel as representative) + matched_streams = self._match_streams_to_channel( + sorted_channels[0], streams, logger, ignore_tags, known_channels, networks_data + ) + # Determine which channels will be updated based on limit + channels_to_update = sorted_channels[:visible_channel_limit] + channels_not_updated = sorted_channels[visible_channel_limit:] + + # Update only the channels within the visible limit if matched_streams: - # Update channels one by one instead of bulk to avoid API issues stream_ids = [s['id'] for s in matched_streams] - try: - payload = { - 'id': channel['id'], - 'streams': stream_ids + for channel in channels_to_update: + try: + payload = { + 'id': channel['id'], + 'streams': stream_ids + } + + logger.info(f"Updating channel {channel['name']} (ID: {channel['id']}) with {len(stream_ids)} streams") + self._patch_api_data( + f"/api/channels/channels/{channel['id']}/", + token, + payload, + settings, + logger + ) + + # Add to list of channels to enable in profile + channels_to_enable.append(channel['id']) + + match_info = { + "channel_id": channel['id'], + "channel_name": channel['name'], + "channel_number": channel.get('channel_number'), + "matched_streams": len(matched_streams), + "stream_names": [s['name'] for s in matched_streams], + "stream_ids": stream_ids, + "will_update": True, + "status": "Updated" + } + all_matches.append(match_info) + channels_updated += 1 + + except Exception as e: + logger.error(f"Failed to update channel {channel['name']} (ID: {channel['id']}): {e}") + match_info = { + "channel_id": channel['id'], + "channel_name": channel['name'], + "channel_number": channel.get('channel_number'), + "matched_streams": len(matched_streams), + "stream_names": [s['name'] for s in matched_streams], + "stream_ids": stream_ids, + "will_update": True, + "status": f"Error: {str(e)}" + } + all_matches.append(match_info) + channels_skipped += 1 + + # Add match info for channels NOT updated (over the limit) + for channel in channels_not_updated: + match_info = { + "channel_id": channel['id'], + "channel_name": channel['name'], + "channel_number": channel.get('channel_number'), + "matched_streams": len(matched_streams), + "stream_names": [s['name'] for s in matched_streams], + "stream_ids": stream_ids, + "will_update": False, + "status": "Skipped (over limit)" } - - logger.info(f"Updating channel {channel['name']} (ID: {channel['id']}) with {len(stream_ids)} streams") - self._patch_api_data( - f"/api/channels/channels/{channel['id']}/", - token, - payload, - settings, - logger - ) - channels_updated += 1 - - except Exception as e: - logger.error(f"Failed to update channel {channel['name']} (ID: {channel['id']}): {e}") + all_matches.append(match_info) channels_skipped += 1 else: - channels_skipped += 1 + # No matches found for this group + for channel in sorted_channels: + match_info = { + "channel_id": channel['id'], + "channel_name": channel['name'], + "channel_number": channel.get('channel_number'), + "matched_streams": 0, + "stream_names": [], + "stream_ids": [], + "will_update": False, + "status": "No matches" + } + all_matches.append(match_info) + channels_skipped += 1 + + # Enable channels in the profile that received streams + channels_enabled = 0 + if channels_to_enable: + logger.info(f"Enabling {len(channels_to_enable)} channels in profile (ID: {profile_id})") + try: + # Build bulk update payload + bulk_payload = [ + {"channel_id": channel_id, "enabled": True} + for channel_id in channels_to_enable + ] + + # Use bulk update endpoint + self._patch_api_data( + f"/api/channels/profiles/{profile_id}/channels/bulk-update/", + token, + bulk_payload, + settings, + logger + ) + channels_enabled = len(channels_to_enable) + logger.info(f"Successfully enabled {channels_enabled} channels in profile") + + except Exception as e: + logger.error(f"Failed to bulk enable channels in profile: {e}") + logger.info("Attempting to enable channels individually...") + + # Fallback: enable channels one by one + for channel_id in channels_to_enable: + try: + self._patch_api_data( + f"/api/channels/profiles/{profile_id}/channels/{channel_id}/", + token, + {"enabled": True}, + settings, + logger + ) + channels_enabled += 1 + except Exception as e2: + logger.error(f"Failed to enable channel {channel_id}: {e2}") # Trigger M3U refresh if channels_updated > 0: @@ -999,7 +1283,7 @@ class Plugin: # Generate results CSV timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - filename = f"channel_addarr_results_{timestamp}.csv" + filename = f"stream_mapparr_results_{timestamp}.csv" filepath = os.path.join("/data/exports", filename) os.makedirs("/data/exports", exist_ok=True) @@ -1012,12 +1296,13 @@ class Plugin: 'matched_streams_count', 'stream_names', 'stream_ids', + 'will_update', 'status' ] writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() - for match in matches: + for match in all_matches: writer.writerow({ 'channel_id': match['channel_id'], 'channel_name': match['channel_name'], @@ -1025,7 +1310,8 @@ class Plugin: 'matched_streams_count': match['matched_streams'], 'stream_names': ' | '.join(match['stream_names']), 'stream_ids': ', '.join(map(str, match['stream_ids'])), - 'status': 'Updated' if match['matched_streams'] > 0 else 'No matches' + 'will_update': 'Yes' if match['will_update'] else 'No', + 'status': match['status'] }) logger.info(f"Results CSV exported to {filepath}") @@ -1035,28 +1321,36 @@ class Plugin: "Stream addition completed:", f"• Total channels processed: {len(channels)}", f"• Channels updated: {channels_updated}", - f"• Channels skipped (no matches or errors): {channels_skipped}", + f"• Channels enabled in profile: {channels_enabled}", + f"• Channels skipped: {channels_skipped}", + f"• Visible channel limit: {visible_channel_limit}", "", f"Results exported to: {filepath}", "", - "Sample updates:" + "Sample updates (✓ = updated):" ] - # Show first 10 updated channels - updated_count = 0 - for match in matches: + # Show first 10 channels + shown_count = 0 + for match in all_matches: + if shown_count >= 10: + break + + update_marker = "✓" if match['will_update'] else "✗" if match['matched_streams'] > 0: stream_preview = ', '.join(match['stream_names'][:3]) if match['matched_streams'] > 3: stream_preview += f" ... (+{match['matched_streams'] - 3} more)" - message_parts.append(f"• {match['channel_name']}: Added {match['matched_streams']} streams") - message_parts.append(f" Streams: {stream_preview}") - updated_count += 1 - if updated_count >= 10: - break + message_parts.append(f"{update_marker} {match['channel_name']}: {match['status']}") + if match['will_update']: + message_parts.append(f" Streams: {stream_preview}") + else: + message_parts.append(f"{update_marker} {match['channel_name']}: {match['status']}") + + shown_count += 1 - if channels_updated > 10: - message_parts.append(f"... and {channels_updated - 10} more channels updated") + if len(all_matches) > 10: + message_parts.append(f"... and {len(all_matches) - 10} more channels") message_parts.append("") message_parts.append("GUI refresh triggered - changes should be visible in the interface shortly.") @@ -1070,6 +1364,299 @@ class Plugin: logger.error(f"Error adding streams to channels: {str(e)}") return {"status": "error", "message": f"Error adding streams to channels: {str(e)}"} + def manage_channel_visibility_action(self, settings, logger): + """Disable all channels in profile, then enable only channels with 0 or 1 stream that are not attached to other channels or duplicates.""" + if not os.path.exists(self.processed_data_file): + return { + "status": "error", + "message": "No processed data found. Please run 'Load/Process Channels' first." + } + + try: + # Get API token + token, error = self._get_api_token(settings, logger) + if error: + return {"status": "error", "message": error} + + # Load processed data + with open(self.processed_data_file, 'r') as f: + processed_data = json.load(f) + + channels = processed_data.get('channels', []) + profile_id = processed_data.get('profile_id') + ignore_tags = processed_data.get('ignore_tags', []) + + if not channels: + return {"status": "error", "message": "No channels found in processed data."} + + if not profile_id: + return {"status": "error", "message": "Profile ID not found in processed data."} + + logger.info(f"Managing visibility for {len(channels)} channels in profile (ID: {profile_id})") + + # Step 1: Disable ALL channels in the profile + logger.info("Step 1: Disabling all channels in profile...") + try: + bulk_disable_payload = [ + {"channel_id": ch['id'], "enabled": False} + for ch in channels + ] + + self._patch_api_data( + f"/api/channels/profiles/{profile_id}/channels/bulk-update/", + token, + bulk_disable_payload, + settings, + logger + ) + logger.info(f"Successfully disabled {len(channels)} channels") + except Exception as e: + logger.error(f"Failed to bulk disable channels: {e}") + logger.info("Attempting to disable channels individually...") + + # Fallback: disable one by one + disabled_count = 0 + for channel in channels: + try: + self._patch_api_data( + f"/api/channels/profiles/{profile_id}/channels/{channel['id']}/", + token, + {"enabled": False}, + settings, + logger + ) + disabled_count += 1 + except Exception as e2: + logger.error(f"Failed to disable channel {channel['id']}: {e2}") + + logger.info(f"Disabled {disabled_count} channels individually") + + # Step 2: Group channels by callsign and get stream counts + logger.info("Step 2: Grouping channels and checking stream assignments...") + + # Group channels by callsign (for OTA) or cleaned name (for regular) + channel_groups = {} + channels_attached_to_others = set() + + for channel in channels: + channel_id = channel['id'] + channel_name = channel['name'] + + # Determine group key + if self._is_ota_channel(channel_name): + ota_info = self._extract_ota_info(channel_name) + if ota_info: + group_key = f"OTA_{ota_info['callsign']}" + else: + group_key = self._clean_channel_name(channel_name, ignore_tags) + else: + group_key = self._clean_channel_name(channel_name, ignore_tags) + + if group_key not in channel_groups: + channel_groups[group_key] = [] + + # Get channel data + try: + channel_data = self._get_api_data( + f"/api/channels/channels/{channel_id}/", + token, + settings, + logger + ) + + # Check if attached to another channel + if channel_data.get('channel') is not None: + channels_attached_to_others.add(channel_id) + logger.info(f" Channel {channel_name} is attached to another channel") + + stream_count = len(channel_data.get('streams', [])) + + channel_groups[group_key].append({ + 'id': channel_id, + 'name': channel_name, + 'stream_count': stream_count, + 'channel_number': channel.get('channel_number'), + 'attached': channel_id in channels_attached_to_others + }) + + except Exception as e: + logger.error(f"Failed to get data for channel {channel_id}: {e}") + + # Step 3: Determine which channels to enable + logger.info("Step 3: Determining which channels to enable...") + channels_to_enable = [] + channel_stream_counts = {} + + for group_key, group_channels in channel_groups.items(): + # Sort channels by priority (same logic as other actions) + sorted_group = self._sort_channels_by_priority([ + {'id': ch['id'], 'name': ch['name'], 'channel_number': ch['channel_number']} + for ch in group_channels + ]) + + # Find channels with 0-1 streams (not attached) + eligible_channels = [ + ch for ch in group_channels + if (ch['stream_count'] == 0 or ch['stream_count'] == 1) and not ch['attached'] + ] + + # If there are eligible channels, enable only the highest priority one + enabled_in_group = False + + for ch in group_channels: + channel_id = ch['id'] + channel_name = ch['name'] + stream_count = ch['stream_count'] + is_attached = ch['attached'] + + # Determine reason for enabling/disabling + if is_attached: + reason = 'Attached to another channel' + should_enable = False + elif stream_count >= 2: + reason = f'{stream_count} streams (too many)' + should_enable = False + elif not enabled_in_group and (stream_count == 0 or stream_count == 1): + # This is the highest priority channel with 0-1 streams + reason = f'{stream_count} stream{"" if stream_count == 1 else "s"}' + should_enable = True + enabled_in_group = True + else: + # Another channel in this group is already enabled + reason = 'Duplicate - higher priority channel in group already enabled' + should_enable = False + + channel_stream_counts[channel_id] = { + 'name': channel_name, + 'stream_count': stream_count, + 'reason': reason + } + + if should_enable: + channels_to_enable.append(channel_id) + logger.info(f" Will enable: {channel_name} ({reason})") + else: + logger.info(f" Will keep disabled: {channel_name} ({reason})") + + # Step 4: Enable selected channels + logger.info(f"Step 4: Enabling {len(channels_to_enable)} channels...") + channels_enabled = 0 + + if channels_to_enable: + try: + bulk_enable_payload = [ + {"channel_id": channel_id, "enabled": True} + for channel_id in channels_to_enable + ] + + self._patch_api_data( + f"/api/channels/profiles/{profile_id}/channels/bulk-update/", + token, + bulk_enable_payload, + settings, + logger + ) + channels_enabled = len(channels_to_enable) + logger.info(f"Successfully enabled {channels_enabled} channels") + + except Exception as e: + logger.error(f"Failed to bulk enable channels: {e}") + logger.info("Attempting to enable channels individually...") + + # Fallback: enable one by one + for channel_id in channels_to_enable: + try: + self._patch_api_data( + f"/api/channels/profiles/{profile_id}/channels/{channel_id}/", + token, + {"enabled": True}, + settings, + logger + ) + channels_enabled += 1 + except Exception as e2: + logger.error(f"Failed to enable channel {channel_id}: {e2}") + + # Trigger M3U refresh + self._trigger_m3u_refresh(token, settings, logger) + + # Generate visibility report CSV + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"stream_mapparr_visibility_{timestamp}.csv" + filepath = os.path.join("/data/exports", filename) + + os.makedirs("/data/exports", exist_ok=True) + + with open(filepath, 'w', newline='', encoding='utf-8') as csvfile: + fieldnames = [ + 'channel_id', + 'channel_name', + 'stream_count', + 'reason', + 'enabled' + ] + writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + writer.writeheader() + + for channel_id, info in channel_stream_counts.items(): + writer.writerow({ + 'channel_id': channel_id, + 'channel_name': info['name'], + 'stream_count': info.get('stream_count', 'N/A'), + 'reason': info.get('reason', ''), + 'enabled': 'Yes' if channel_id in channels_to_enable else 'No' + }) + + logger.info(f"Visibility report exported to {filepath}") + + # Count channels by category + channels_with_0_streams = sum(1 for info in channel_stream_counts.values() if info.get('stream_count') == 0) + channels_with_1_stream = sum(1 for info in channel_stream_counts.values() if info.get('stream_count') == 1) + channels_with_2plus_streams = sum(1 for info in channel_stream_counts.values() if isinstance(info.get('stream_count'), int) and info.get('stream_count') >= 2) + channels_attached = len(channels_attached_to_others) + channels_duplicates = len([info for info in channel_stream_counts.values() if 'Duplicate' in info.get('reason', '')]) + + # Create summary message + message_parts = [ + "Channel visibility management completed:", + f"• Total channels processed: {len(channels)}", + f"• Channels disabled: {len(channels) - channels_enabled}", + f"• Channels enabled: {channels_enabled}", + "", + "Breakdown:", + f"• Enabled (0-1 streams): {channels_enabled} channels", + f"• Disabled (2+ streams): {channels_with_2plus_streams} channels", + f"• Disabled (duplicates): {channels_duplicates} channels", + f"• Disabled (attached): {channels_attached} channels", + "", + f"Visibility report exported to: {filepath}", + "", + "Sample enabled channels:" + ] + + # Show first 10 enabled channels + shown_count = 0 + for channel_id in channels_to_enable[:10]: + info = channel_stream_counts.get(channel_id) + if info: + message_parts.append(f"✓ {info['name']}: {info.get('reason', 'N/A')}") + shown_count += 1 + + if len(channels_to_enable) > 10: + message_parts.append(f"... and {len(channels_to_enable) - 10} more enabled channels") + + message_parts.append("") + message_parts.append("GUI refresh triggered - changes should be visible in the interface shortly.") + + return { + "status": "success", + "message": "\n".join(message_parts) + } + + except Exception as e: + logger.error(f"Error managing channel visibility: {str(e)}") + return {"status": "error", "message": f"Error managing channel visibility: {str(e)}"} + # Export fields and actions for Dispatcharr plugin system fields = Plugin.fields @@ -1080,5 +1667,5 @@ plugin = Plugin() plugin_instance = Plugin() # Alternative export names in case Dispatcharr looks for these -channel_addarr = Plugin() -CHANNEL_ADDARR = Plugin() \ No newline at end of file +stream_mapparr = Plugin() +STREAM_MAPPARR = Plugin() \ No newline at end of file