76
76
get_similar_tracks ,
77
77
get_stream ,
78
78
get_track ,
79
+ get_track_lyrics ,
80
+ get_tracks_by_isrc ,
79
81
library_items_add_remove ,
80
82
remove_playlist_tracks ,
81
83
search ,
@@ -545,20 +547,24 @@ async def library_remove(self, prov_item_id: str, media_type: MediaType) -> bool
545
547
async def add_playlist_tracks (self , prov_playlist_id : str , prov_track_ids : list [str ]) -> None :
546
548
"""Add track(s) to playlist."""
547
549
tidal_session = await self ._get_tidal_session ()
548
- return await add_playlist_tracks (tidal_session , prov_playlist_id , prov_track_ids )
550
+ await add_playlist_tracks (tidal_session , prov_playlist_id , prov_track_ids )
549
551
550
552
async def remove_playlist_tracks (
551
553
self , prov_playlist_id : str , positions_to_remove : tuple [int , ...]
552
554
) -> None :
553
555
"""Remove track(s) from playlist."""
554
- prov_track_ids = []
555
556
tidal_session = await self ._get_tidal_session ()
557
+ prov_track_ids : list [str ] = []
558
+ # Get tracks by position
556
559
for pos in positions_to_remove :
557
- for tidal_track in await get_playlist_tracks (
560
+ tracks = await get_playlist_tracks (
558
561
tidal_session , prov_playlist_id , limit = 1 , offset = pos - 1
559
- ):
560
- prov_track_ids .append (tidal_track .id )
561
- return await remove_playlist_tracks (tidal_session , prov_playlist_id , prov_track_ids )
562
+ )
563
+ if tracks and len (tracks ) > 0 :
564
+ prov_track_ids .append (str (tracks [0 ].id ))
565
+
566
+ if prov_track_ids :
567
+ await remove_playlist_tracks (tidal_session , prov_playlist_id , prov_track_ids )
562
568
563
569
async def create_playlist (self , name : str ) -> Playlist :
564
570
"""Create a new playlist on provider with given name."""
@@ -577,17 +583,30 @@ async def get_stream_details(
577
583
"""Return the content details for the given track when it will be streamed."""
578
584
tidal_session = await self ._get_tidal_session ()
579
585
# make sure a valid track is requested.
580
- if not (track := await get_track (tidal_session , item_id )):
581
- msg = f"track { item_id } not found"
582
- raise MediaNotFoundError (msg )
586
+ # Try direct track lookup first with exception handling
587
+ try :
588
+ track = await get_track (tidal_session , item_id )
589
+ except MediaNotFoundError :
590
+ # Fallback to ISRC lookup
591
+ self .logger .info (
592
+ """Track %s not found, attempting fallback by ISRC.
593
+ It's likely that this track has a new ID upstream in Tidal's WebApp.""" ,
594
+ item_id ,
595
+ )
596
+ track = await self ._get_track_by_isrc (item_id , tidal_session )
597
+ if not track :
598
+ raise MediaNotFoundError (f"Track { item_id } not found" )
599
+
583
600
stream : TidalStream = await get_stream (track )
584
601
manifest = stream .get_stream_manifest ()
585
- if stream .is_mpd :
602
+
603
+ url = (
586
604
# for mpeg-dash streams we just pass the complete base64 manifest
587
- url = f"data:application/dash+xml;base64,{ manifest .manifest } "
588
- else :
605
+ f"data:application/dash+xml;base64,{ manifest .manifest } "
606
+ if stream . is_mpd
589
607
# as far as I can oversee a BTS stream is just a single URL
590
- url = manifest .urls [0 ]
608
+ else manifest .urls [0 ]
609
+ )
591
610
592
611
return StreamDetails (
593
612
item_id = track .id ,
@@ -632,8 +651,9 @@ async def get_track(self, prov_track_id: str) -> Track:
632
651
track = self ._parse_track (track_obj )
633
652
# get some extra details for the full track info
634
653
with suppress (tidal_exceptions .MetadataNotAvailable , AttributeError ):
635
- lyrics : TidalLyrics = await asyncio .to_thread (track_obj .lyrics )
636
- track .metadata .lyrics = lyrics .text
654
+ lyrics : TidalLyrics = await get_track_lyrics (tidal_session , prov_track_id )
655
+ if lyrics and hasattr (lyrics , "text" ):
656
+ track .metadata .lyrics = lyrics .text
637
657
return track
638
658
except tidal_exceptions .ObjectNotFound as err :
639
659
raise MediaNotFoundError from err
@@ -713,6 +733,58 @@ def inner() -> TidalSession:
713
733
714
734
return await asyncio .to_thread (inner )
715
735
736
+ async def _get_track_by_isrc (
737
+ self , item_id : str , tidal_session : TidalSession
738
+ ) -> TidalTrack | None :
739
+ """Get track by ISRC from library item, with caching."""
740
+ # Try to get from cache first
741
+ cache_key = f"isrc_map_{ item_id } "
742
+ cached_track_id = await self .mass .cache .get (
743
+ cache_key , category = CacheCategory .DEFAULT , base_key = self .lookup_key
744
+ )
745
+
746
+ if cached_track_id :
747
+ self .logger .debug (
748
+ "Using cached track id" ,
749
+ )
750
+ try :
751
+ return await get_track (tidal_session , str (cached_track_id ))
752
+ except MediaNotFoundError :
753
+ # Track no longer exists, invalidate cache
754
+ await self .mass .cache .delete (
755
+ cache_key , category = CacheCategory .DEFAULT , base_key = self .lookup_key
756
+ )
757
+
758
+ # Lookup by ISRC if no cache or cached track not found
759
+ library_track = await self .mass .music .tracks .get_library_item_by_prov_id (
760
+ item_id , self .instance_id
761
+ )
762
+ if not library_track :
763
+ return None
764
+
765
+ isrc = next (
766
+ (
767
+ id_value
768
+ for id_type , id_value in library_track .external_ids
769
+ if id_type == ExternalID .ISRC
770
+ ),
771
+ None ,
772
+ )
773
+ if not isrc :
774
+ return None
775
+
776
+ self .logger .debug ("Attempting track lookup by ISRC: %s" , isrc )
777
+ tracks : list [TidalTrack ] = await get_tracks_by_isrc (tidal_session , isrc )
778
+ if not tracks :
779
+ return None
780
+
781
+ # Cache the mapping for future use
782
+ await self .mass .cache .set (
783
+ cache_key , tracks [0 ].id , category = CacheCategory .DEFAULT , base_key = self .lookup_key
784
+ )
785
+
786
+ return tracks [0 ]
787
+
716
788
# Parsers
717
789
718
790
def _parse_artist (self , artist_obj : TidalArtist ) -> Artist :
0 commit comments