-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathapp.py
More file actions
1434 lines (1240 loc) · 63.5 KB
/
Copy pathapp.py
File metadata and controls
1434 lines (1240 loc) · 63.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import os
import json
import logging
from datetime import datetime
from flask import Flask, render_template, request, jsonify
import pandas as pd
import numpy as np
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = Flask(__name__)
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev-secret-key-change-in-production')
ROOT = os.path.abspath(os.path.dirname(__file__))
EDA_RESULTS_DIR = os.path.join(ROOT, "EDA", "results")
TABLES_DIRS = [
os.path.join(EDA_RESULTS_DIR, "tables"),
os.path.join(ROOT, "EDA", "tables"),
]
def get_tables_directory():
"""Get the tables directory path, preferring EDA/results/tables"""
for path in TABLES_DIRS:
if os.path.isdir(path):
return path
logger.error("No tables directory found")
return None
TABLES_DIR = get_tables_directory()
def load_csv(filename):
"""Load CSV or JSON file from tables directory with proper error handling"""
if not TABLES_DIR:
logger.error(f"Cannot load {filename}: no tables directory")
return None
filepath = os.path.join(TABLES_DIR, filename)
if not os.path.exists(filepath):
logger.warning(f"File not found: {filename}")
return None
try:
if filename.endswith('.json'):
with open(filepath, 'r') as f:
data = json.load(f)
logger.info(f"Loaded {filename}: JSON data")
return data
else:
df = pd.read_csv(filepath)
logger.info(f"Loaded {filename}: {len(df)} rows, {len(df.columns)} columns")
return df
except Exception as e:
logger.error(f"Error loading {filename}: {str(e)}")
return None
def load_eda_data():
"""Load EDA analysis data from results directory"""
try:
# Load summary data
summaries = {}
reports_dir = os.path.join(EDA_RESULTS_DIR, "reports")
if os.path.exists(reports_dir):
for file in os.listdir(reports_dir):
if file.endswith('_summary.json'):
analysis_name = file.replace('_summary.json', '')
filepath = os.path.join(reports_dir, file)
with open(filepath, 'r') as f:
summaries[analysis_name] = json.load(f)
# Load table data
tables = {}
if TABLES_DIR:
for file in os.listdir(TABLES_DIR):
if file.endswith('.csv'):
table_name = file.replace('.csv', '')
tables[table_name] = load_csv(file)
# Also load processed dataset from processed_data directory
processed_data_dir = os.path.join(EDA_RESULTS_DIR, "processed_data")
if os.path.exists(processed_data_dir):
for file in os.listdir(processed_data_dir):
if file.endswith('.csv'):
table_name = file.replace('.csv', '')
filepath = os.path.join(processed_data_dir, file)
tables[table_name] = load_csv(filepath)
# Load plot data for regeneration
plot_data = {}
# Check both data and processed_data directories
for data_dir_name in ["data", "processed_data"]:
data_dir = os.path.join(EDA_RESULTS_DIR, data_dir_name)
if os.path.exists(data_dir):
for file in os.listdir(data_dir):
if file.endswith('_plot_data.json'):
analysis_name = file.replace('_plot_data.json', '')
filepath = os.path.join(data_dir, file)
with open(filepath, 'r') as f:
plot_data[analysis_name] = json.load(f)
logger.info(f"Loaded EDA data: {len(summaries)} summaries, {len(tables)} tables, {len(plot_data)} plot datasets")
return {
'summaries': summaries,
'tables': tables,
'plot_data': plot_data
}
except Exception as e:
logger.error(f"Error loading EDA data: {str(e)}")
return None
# Load EDA data on startup
EDA_DATA = load_eda_data()
# Country code to country name mapping
COUNTRY_MAPPING = {
'US': 'United States', 'GB': 'United Kingdom', 'BR': 'Brazil', 'DE': 'Germany', 'JP': 'Japan',
'FR': 'France', 'CA': 'Canada', 'AU': 'Australia', 'IT': 'Italy', 'ES': 'Spain', 'MX': 'Mexico',
'IN': 'India', 'RU': 'Russia', 'CN': 'China', 'KR': 'South Korea', 'NL': 'Netherlands',
'SE': 'Sweden', 'NO': 'Norway', 'DK': 'Denmark', 'FI': 'Finland', 'PL': 'Poland', 'CZ': 'Czech Republic',
'HU': 'Hungary', 'AT': 'Austria', 'CH': 'Switzerland', 'BE': 'Belgium', 'PT': 'Portugal',
'GR': 'Greece', 'TR': 'Turkey', 'IL': 'Israel', 'AE': 'United Arab Emirates', 'SA': 'Saudi Arabia',
'EG': 'Egypt', 'ZA': 'South Africa', 'NG': 'Nigeria', 'KE': 'Kenya', 'MA': 'Morocco',
'TN': 'Tunisia', 'DZ': 'Algeria', 'GH': 'Ghana', 'UG': 'Uganda', 'ZW': 'Zimbabwe',
'BW': 'Botswana', 'ZM': 'Zambia', 'MW': 'Malawi', 'SG': 'Singapore', 'TH': 'Thailand',
'MY': 'Malaysia', 'ID': 'Indonesia', 'PH': 'Philippines', 'VN': 'Vietnam', 'TW': 'Taiwan',
'HK': 'Hong Kong', 'NZ': 'New Zealand', 'AR': 'Argentina', 'CL': 'Chile', 'CO': 'Colombia',
'PE': 'Peru', 'VE': 'Venezuela', 'UY': 'Uruguay', 'PY': 'Paraguay', 'BO': 'Bolivia',
'EC': 'Ecuador', 'CR': 'Costa Rica', 'PA': 'Panama', 'GT': 'Guatemala', 'HN': 'Honduras',
'SV': 'El Salvador', 'NI': 'Nicaragua', 'CU': 'Cuba', 'DO': 'Dominican Republic',
'JM': 'Jamaica', 'TT': 'Trinidad and Tobago', 'BB': 'Barbados'
}
# Cache for expensive operations
from functools import lru_cache
import time
# Simple in-memory cache with TTL
class TTLCache:
def __init__(self, ttl_seconds=300): # 5 minute default TTL
self.cache = {}
self.ttl = ttl_seconds
def get(self, key):
if key in self.cache:
value, timestamp = self.cache[key]
if time.time() - timestamp < self.ttl:
return value
else:
del self.cache[key]
return None
def set(self, key, value):
self.cache[key] = (value, time.time())
def clear(self):
self.cache.clear()
# Global cache instance
cache = TTLCache(ttl_seconds=300) # 5 minute cache
def add_release_date_column(df):
"""Create a release_date column from existing date fields."""
if df is None or df.empty:
return df
working_df = df.copy()
release_source = None
if 'album_release_date' in working_df.columns:
release_source = working_df['album_release_date']
elif 'snapshot_date' in working_df.columns:
release_source = working_df['snapshot_date']
if release_source is not None:
working_df['release_date'] = pd.to_datetime(release_source, errors='coerce')
working_df['release_date'] = working_df['release_date'].fillna(method='ffill')
else:
working_df['release_date'] = pd.NaT
return working_df
def generate_charts_from_eda():
"""Generate meaningful business intelligence charts from cached EDA summaries."""
charts_data = {}
# Use real EDA data directly instead of relying on filtered_df
if EDA_DATA is None:
logger.error("EDA data not available")
return charts_data
# 1. Audio Feature Correlation Network - Use real EDA correlations
audio_summary = EDA_DATA['summaries'].get('audio_features_analysis', {})
correlations = audio_summary.get('strongest_correlations', [])
if correlations:
charts_data['audio_correlations'] = {
'type': 'network',
'features': audio_summary.get('available_features', []),
'correlations': correlations,
'title': 'Audio Feature Correlation Network',
'insight': f'Identifies {len(correlations)} significant relationships between audio features'
}
# 2. Country Music Activity Analysis - Use real geographical data
geo_summary = EDA_DATA['summaries'].get('geographical_analysis', {})
top_countries = geo_summary.get('top_10_countries', {})
if top_countries:
# Create country activity chart
countries = list(top_countries.keys())
song_counts = list(top_countries.values())
charts_data['country_activity'] = {
'type': 'bar',
'countries': [get_country_name(c) for c in countries],
'song_counts': song_counts,
'title': 'Top Countries by Music Activity',
'insight': f'Shows music activity across {len(countries)} most active countries'
}
# 3. Temporal Trend Analysis - Create seasonal pattern analysis
processed_df = EDA_DATA['tables'].get('processed_spotify_dataset')
if processed_df is not None and 'snapshot_date' in processed_df.columns:
# Convert snapshot_date to datetime and extract month
processed_df['snapshot_date'] = pd.to_datetime(processed_df['snapshot_date'], errors='coerce')
processed_df = processed_df.dropna(subset=['snapshot_date'])
# Extract month (1-12) for seasonal analysis
processed_df['month'] = processed_df['snapshot_date'].dt.month
# Count songs per month across all years
monthly_counts = processed_df.groupby('month').size()
# Calculate average releases per month across all years
monthly_averages = {}
month_names = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
for month_num in range(1, 13):
count = monthly_counts.get(month_num, 0)
monthly_averages[month_names[month_num - 1]] = int(count)
# Find peak and low months
sorted_months = sorted(monthly_averages.items(), key=lambda x: x[1], reverse=True)
peak_month = sorted_months[0]
low_month = sorted_months[-1]
charts_data['temporal_analysis'] = {
'type': 'seasonal_bar',
'monthly_averages': monthly_averages,
'title': 'Seasonal Music Release Patterns',
'insight': f'Peak release month: {peak_month[0]} ({peak_month[1]:,} avg), Low month: {low_month[0]} ({low_month[1]:,} avg)'
}
else:
# Fallback to EDA data if no release_date column
monthly_data = EDA_DATA['tables'].get('monthly_song_counts')
if monthly_data is not None and not monthly_data.empty:
monthly_values = monthly_data.iloc[:, 0].tolist()
# Create a realistic timeline assuming recent years
monthly_timeline = {}
for i, count in enumerate(monthly_values):
# Assume data starts from 2020-01
year = 2020 + (i // 12)
month = (i % 12) + 1
month_label = f"{year}-{month:02d}"
monthly_timeline[month_label] = int(count)
charts_data['temporal_analysis'] = {
'type': 'single_line',
'monthly': monthly_timeline,
'title': 'Music Release Patterns Over Time',
'insight': f'Shows monthly song releases across {len(monthly_timeline)} months'
}
# 5. Top Artists Analysis - Use processed dataset to get unique song counts
processed_df = EDA_DATA['tables'].get('processed_spotify_dataset')
if processed_df is not None and 'artists' in processed_df.columns:
# Count unique songs per artist (not total entries)
artist_counts = processed_df.groupby('artists')['spotify_id'].nunique().sort_values(ascending=False).head(10)
charts_data['top_artists'] = {
'type': 'bar',
'artists': artist_counts.index.tolist(),
'song_counts': artist_counts.values.tolist(),
'title': 'Top Artists by Unique Song Count',
'insight': f'Shows artists with the most unique songs (not duplicate entries)'
}
# 7. Audio Feature Distribution Analysis
if processed_df is not None and 'danceability' in processed_df.columns:
# Sample audio features for distribution analysis
sample_size = min(5000, len(processed_df))
sample_df = processed_df.sample(n=sample_size)
audio_features = ['danceability', 'energy', 'valence', 'acousticness']
feature_stats = {}
for feature in audio_features:
if feature in sample_df.columns:
feature_stats[feature] = {
'mean': round(sample_df[feature].mean(), 3),
'std': round(sample_df[feature].std(), 3),
'min': round(sample_df[feature].min(), 3),
'max': round(sample_df[feature].max(), 3)
}
charts_data['audio_feature_distribution'] = {
'type': 'box_plot',
'features': list(feature_stats.keys()),
'data': feature_stats,
'title': 'Audio Feature Distribution Analysis',
'insight': f'Statistical distribution of {len(feature_stats)} key audio features'
}
# 4. Explicitness Impact Analysis - Use real EDA data
explicitness_summary = EDA_DATA['summaries'].get('explicitness_analysis', {})
if explicitness_summary:
charts_data['explicitness_impact'] = {
'type': 'comparison',
'explicit': {
'mean': round(explicitness_summary.get('group2_mean', 0), 1),
'median': round(explicitness_summary.get('explicitness_groups_summary', {}).get('median', {}).get('True', 0), 1),
'count': explicitness_summary.get('explicit_songs_count', 0),
'percentage': round(explicitness_summary.get('explicit_songs_percentage', 0), 1)
},
'clean': {
'mean': round(explicitness_summary.get('group1_mean', 0), 1),
'median': round(explicitness_summary.get('explicitness_groups_summary', {}).get('median', {}).get('False', 0), 1),
'count': explicitness_summary.get('non_explicit_songs_count', 0),
'percentage': round(explicitness_summary.get('non_explicit_songs_percentage', 0), 1)
},
'title': 'Explicitness vs Popularity Analysis',
'insight': f'Explicit songs make up {round(explicitness_summary.get("explicit_songs_percentage", 0), 1)}% of the dataset'
}
return charts_data
def build_charts_from_filtered_data(filtered_df, filters=None):
"""Build chart payloads that respond directly to the active filters."""
charts_data = {}
if filtered_df is None or filtered_df.empty:
return charts_data
df = filtered_df.replace([np.inf, -np.inf], np.nan)
# 1. Country activity driven by filtered dataset
if 'country' in df.columns:
# Count unique songs per country (not total entries) to avoid duplicates
if 'spotify_id' in df.columns:
country_counts = df.groupby('country')['spotify_id'].nunique().sort_values(ascending=False).head(10)
else:
# Fallback to row count if spotify_id not available
country_counts = df['country'].value_counts().head(10)
if not country_counts.empty:
charts_data['country_activity'] = {
'type': 'bar',
'countries': [get_country_name(code) for code in country_counts.index],
'song_counts': country_counts.astype(int).tolist(),
'title': 'Top Countries (Filtered View)',
'insight': f"{country_counts.sum():,} tracks across {len(country_counts)} highlighted markets"
}
# 2. Temporal trends using release dates
if 'release_date' in df.columns:
release_df = df.dropna(subset=['release_date']).copy()
if not release_df.empty:
release_df['release_date'] = pd.to_datetime(release_df['release_date'], errors='coerce')
release_df = release_df.dropna(subset=['release_date'])
if not release_df.empty:
release_df['month_label'] = release_df['release_date'].dt.to_period('M').astype(str)
monthly_counts = release_df.groupby('month_label').size().sort_index()
recent_months = monthly_counts.tail(24)
if not recent_months.empty:
charts_data['temporal_analysis'] = {
'type': 'single_line',
'monthly': recent_months.to_dict(),
'title': 'Release Volume Over Time',
'insight': f"Shows last {len(recent_months)} months after applying filters"
}
# 4. Audio feature stats for filtered sample
audio_features = ['danceability', 'energy', 'valence', 'acousticness', 'speechiness', 'instrumentalness', 'liveness']
available_audio_features = [feature for feature in audio_features if feature in df.columns]
feature_stats = {}
for feature in available_audio_features:
series = df[feature].dropna()
if series.empty:
continue
std_value = round(series.std(), 3) if len(series) > 1 else 0.0
feature_stats[feature] = {
'mean': round(series.mean(), 3),
'std': std_value if not np.isnan(std_value) else 0.0,
'min': round(series.min(), 3),
'max': round(series.max(), 3)
}
if feature_stats:
charts_data['audio_feature_distribution'] = {
'type': 'box_plot',
'features': list(feature_stats.keys()),
'data': feature_stats,
'title': 'Audio Features (Filtered Sample)',
'insight': f"{len(feature_stats)} feature distributions recalculated with active filters"
}
# 5. Explicit vs clean comparison
if {'is_explicit', 'popularity'}.issubset(df.columns):
explicit_df = df[['is_explicit', 'popularity']].dropna()
if not explicit_df.empty:
explicit_df['is_explicit'] = explicit_df['is_explicit'].astype(bool)
stats = explicit_df.groupby('is_explicit')['popularity'].agg(['mean', 'median', 'count'])
total = stats['count'].sum()
explicit_count = int(stats.loc[True, 'count']) if True in stats.index else 0
clean_count = int(stats.loc[False, 'count']) if False in stats.index else 0
charts_data['explicitness_impact'] = {
'type': 'comparison',
'explicit': {
'mean': round(stats.loc[True, 'mean'], 1) if True in stats.index else 0,
'median': round(stats.loc[True, 'median'], 1) if True in stats.index else 0,
'count': explicit_count,
'percentage': round((explicit_count / total) * 100, 1) if total else 0
},
'clean': {
'mean': round(stats.loc[False, 'mean'], 1) if False in stats.index else 0,
'median': round(stats.loc[False, 'median'], 1) if False in stats.index else 0,
'count': clean_count,
'percentage': round((clean_count / total) * 100, 1) if total else 0
},
'title': 'Explicitness vs Popularity (Filtered)',
'insight': f"{explicit_count:,} explicit tracks vs {clean_count:,} clean tracks in current view"
}
# 5. Correlation network recalculated from filtered data
corr_features = [f for f in audio_features if f in df.columns]
if len(corr_features) >= 3:
corr_matrix = df[corr_features].corr().stack().reset_index()
corr_matrix.columns = ['feature1', 'feature2', 'correlation']
corr_matrix = corr_matrix[corr_matrix['feature1'] != corr_matrix['feature2']]
corr_matrix['abs_corr'] = corr_matrix['correlation'].abs()
corr_matrix = corr_matrix[corr_matrix['abs_corr'] >= 0.3].sort_values('abs_corr', ascending=False)
correlations = []
seen_pairs = set()
for _, row in corr_matrix.iterrows():
pair = tuple(sorted([row['feature1'], row['feature2']]))
if pair in seen_pairs:
continue
seen_pairs.add(pair)
correlations.append({
'feature1': row['feature1'],
'feature2': row['feature2'],
'correlation': round(row['correlation'], 3),
'strength': 'strong' if row['abs_corr'] >= 0.6 else 'moderate'
})
if len(correlations) >= 15:
break
if correlations:
charts_data['audio_correlations'] = {
'type': 'network',
'features': corr_features,
'correlations': correlations,
'title': 'Audio Feature Network (Filtered)',
'insight': f"{len(correlations)} strongest relationships recalculated from current data slice"
}
return charts_data
def generate_insightful_charts(filtered_df, filters=None):
"""Return charts that respect filters, falling back to cached EDA summaries when necessary."""
if filtered_df is None or filtered_df.empty:
logger.warning("Filtered dataset empty; falling back to EDA charts")
return generate_charts_from_eda()
try:
charts = build_charts_from_filtered_data(filtered_df, filters=filters)
if charts and len(charts) > 0:
return charts
logger.warning("No charts built from filtered data; using EDA fallback")
except Exception as exc:
logger.error(f"Error building filtered charts: {exc}", exc_info=True)
return generate_charts_from_eda()
def apply_filters_to_dataset(dataset, countries=None, pop_min=None, pop_max=None,
release_start=None, release_end=None, trend=None):
"""Apply filters to dataset and return filtered DataFrame"""
if dataset is None or dataset.empty:
return dataset
filtered_df = dataset.copy()
if 'release_date' in filtered_df.columns:
filtered_df['release_date'] = pd.to_datetime(filtered_df['release_date'], errors='coerce')
# Apply filters
if countries and 'country' in filtered_df.columns:
filtered_df = filtered_df[filtered_df['country'].isin(countries)]
if pop_min is not None and 'popularity' in filtered_df.columns:
filtered_df = filtered_df[filtered_df['popularity'] >= pop_min]
if pop_max is not None and 'popularity' in filtered_df.columns:
filtered_df = filtered_df[filtered_df['popularity'] <= pop_max]
if release_start and 'release_date' in filtered_df.columns:
start_dt = pd.to_datetime(release_start, errors='coerce')
if pd.notna(start_dt):
filtered_df = filtered_df[filtered_df['release_date'] >= start_dt]
if release_end and 'release_date' in filtered_df.columns:
end_dt = pd.to_datetime(release_end, errors='coerce')
if pd.notna(end_dt):
filtered_df = filtered_df[filtered_df['release_date'] <= end_dt]
# Apply trend filter
if trend == 'growing' and {'popularity', 'release_date'}.issubset(filtered_df.columns):
filtered_df = filtered_df[
(filtered_df['popularity'] > 70) &
(filtered_df['release_date'] >= '2023-01-01')
]
elif trend == 'declining' and {'popularity', 'release_date'}.issubset(filtered_df.columns):
filtered_df = filtered_df[
(filtered_df['popularity'] < 30) |
(filtered_df['release_date'] < '2022-01-01')
]
elif trend == 'steady' and 'popularity' in filtered_df.columns:
filtered_df = filtered_df[
(filtered_df['popularity'] >= 30) &
(filtered_df['popularity'] <= 70)
]
return filtered_df
def validate_filter_params(params):
"""Validate and sanitize filter parameters"""
validated = {}
# Validate countries (should be 2-letter codes)
if params.get('countries'):
countries = params['countries']
if isinstance(countries, str):
countries = [countries]
validated['countries'] = [c.upper() for c in countries if len(c) == 2 and c.isalpha()]
# Validate popularity range
pop_min = params.get('pop_min')
pop_max = params.get('pop_max')
if pop_min is not None:
try:
pop_min = int(pop_min)
validated['pop_min'] = max(0, min(100, pop_min))
except (ValueError, TypeError):
validated['pop_min'] = None
if pop_max is not None:
try:
pop_max = int(pop_max)
validated['pop_max'] = max(0, min(100, pop_max))
except (ValueError, TypeError):
validated['pop_max'] = None
# Validate date range
release_start = params.get('release_start')
release_end = params.get('release_end')
if release_start:
try:
pd.to_datetime(release_start)
validated['release_start'] = release_start
except (ValueError, TypeError):
validated['release_start'] = None
if release_end:
try:
pd.to_datetime(release_end)
validated['release_end'] = release_end
except (ValueError, TypeError):
validated['release_end'] = None
# Validate trend
trend = params.get('trend')
if trend and trend in ['growing', 'steady', 'declining']:
validated['trend'] = trend
return validated
def format_api_response(data, status='success', message=None, metadata=None):
"""Format API response with consistent structure"""
response = {
'status': status,
'data': data,
'timestamp': datetime.now().isoformat()
}
if message:
response['message'] = message
if metadata:
response['metadata'] = metadata
return response
def get_country_name(country_code):
"""Convert country code to full country name"""
return COUNTRY_MAPPING.get(country_code, country_code)
def extract_filter_params(request):
"""Extract and validate filter parameters from request"""
raw_params = {
'countries': request.args.getlist('country'),
'pop_min': request.args.get('popMin', type=int),
'pop_max': request.args.get('popMax', type=int),
'release_start': request.args.get('releaseStart'),
'release_end': request.args.get('releaseEnd'),
'trend': request.args.get('trend')
}
return validate_filter_params(raw_params)
# Load the main dataset for filtering using real EDA data
def get_filtered_dataset(filters=None):
"""Get filtered dataset using actual Spotify data instead of synthetic data"""
try:
if EDA_DATA is None:
logger.error("EDA data not available")
return None
# Try to load the actual processed dataset first
processed_df = EDA_DATA['tables'].get('processed_spotify_dataset')
if processed_df is not None and len(processed_df) > 0:
logger.info(f"Using actual processed dataset with {len(processed_df)} records")
# Sample the data for performance (take every 200th record to get ~10k records)
sample_df = processed_df.iloc[::200].copy()
logger.info(f"Sampled dataset to {len(sample_df)} records for performance")
return add_release_date_column(sample_df)
# If no processed dataset, try to load the original dataset
try:
# Look for the original dataset file
dataset_path = os.path.join(os.path.dirname(__file__), 'spotify_dataset.csv')
if os.path.exists(dataset_path):
logger.info("Loading original Spotify dataset")
df = pd.read_csv(dataset_path)
# Sample for performance
sample_df = df.iloc[::200].copy()
logger.info(f"Loaded and sampled original dataset to {len(sample_df)} records")
return add_release_date_column(sample_df)
except Exception as e:
logger.warning(f"Could not load original dataset: {str(e)}")
logger.error("Processed Spotify dataset not available")
return None
except Exception as e:
logger.error(f"Could not create filtered dataset: {str(e)}")
return None
# Remove the large MAIN_DATASET - we'll load data on-demand
# MAIN_DATASET = load_main_dataset() # This was loading 2M+ records into memory
def build_manifest():
"""Build a simplified manifest with only essential static charts"""
charts = []
# Only include charts that provide real value and aren't redundant with dynamic charts
# Remove the complex static chart generation that doesn't use filtered data
return charts
def compute_kpis(filtered_data=None):
# Use filtered data if provided, otherwise get a sample dataset
data_to_use = filtered_data if filtered_data is not None else get_filtered_dataset()
# Calculate total tracks from the actual dataset
total_tracks = len(data_to_use) if data_to_use is not None and not data_to_use.empty else 0
# Calculate countries covered and averages from the dataset
countries_covered = 0
avg_energy = 0.0
avg_danceability = 0.0
if data_to_use is not None and not data_to_use.empty:
countries_covered = data_to_use['country'].nunique()
avg_energy = round(data_to_use['energy'].mean(), 3)
avg_danceability = round(data_to_use['danceability'].mean(), 3)
# Use real EDA data for accurate KPIs when no filtered data is provided
if filtered_data is None and EDA_DATA is not None:
# Get accurate numbers from EDA analysis
basic_summary = EDA_DATA['summaries'].get('basic_data', {})
geo_summary = EDA_DATA['summaries'].get('geographical_analysis', {})
# Use real total tracks from EDA
total_tracks = basic_summary.get('total_records', total_tracks)
# Use real country count from EDA
countries_covered = geo_summary.get('total_countries', countries_covered)
# Use real audio features from EDA if available
audio_summary = EDA_DATA['summaries'].get('audio_features_analysis', {})
if audio_summary:
# These would need to be calculated from the actual data, but for now use the synthetic values
pass
return {
"total_tracks": total_tracks,
"avg_energy": avg_energy,
"avg_danceability": avg_danceability,
"countries_covered": countries_covered,
"last_ingest": datetime.now().strftime("%Y-%m-%d %H:%M")
}
@app.route("/")
def index():
manifest = build_manifest()
kpis = compute_kpis()
return render_template("index.html", kpis=kpis, manifest=manifest)
@app.route("/api/filter-options")
def get_filter_options():
"""Get available filter options with proper error handling and caching"""
try:
# Check cache first
cache_key = "filter_options"
cached_options = cache.get(cache_key)
if cached_options:
logger.info("Returning cached filter options")
return jsonify(format_api_response(cached_options, message="Filter options loaded from cache"))
# Get dataset for filter options
dataset = get_filtered_dataset()
if dataset is None or dataset.empty:
logger.error("Dataset not available")
return jsonify(format_api_response(None, status='error', message="Dataset not loaded")), 500
# Create country options with both codes and names
country_codes = sorted(dataset['country'].dropna().unique().tolist()) if 'country' in dataset.columns else []
country_options = [{"code": code, "name": get_country_name(code)} for code in country_codes]
# Build filter options based on available columns
filter_options = {
"countries": country_options,
}
if 'popularity' in dataset.columns and not dataset['popularity'].dropna().empty:
filter_options["popularity_range"] = {
"min": int(dataset['popularity'].min()),
"max": int(dataset['popularity'].max())
}
else:
filter_options["popularity_range"] = {"min": 0, "max": 100}
if 'release_date' in dataset.columns and not dataset['release_date'].dropna().empty:
filter_options["date_range"] = {
"start": dataset['release_date'].min().strftime('%Y-%m-%d'),
"end": dataset['release_date'].max().strftime('%Y-%m-%d')
}
else:
filter_options["date_range"] = {"start": "2020-01-01", "end": "2024-12-31"}
# Cache the result
cache.set(cache_key, filter_options)
metadata = {
"total_countries": len(country_options),
"dataset_size": len(dataset)
}
return jsonify(format_api_response(filter_options, message="Filter options loaded successfully", metadata=metadata))
except Exception as e:
logger.error(f"Error getting filter options: {str(e)}")
return jsonify(format_api_response(None, status='error', message="Internal server error")), 500
@app.route("/api/filtered-data")
def get_filtered_data():
"""Get filtered data based on query parameters with proper error handling"""
try:
# Get dataset
dataset = get_filtered_dataset()
if dataset is None or dataset.empty:
logger.error("Dataset not available")
return jsonify(format_api_response(None, status='error', message="Dataset not loaded")), 500
# Extract and validate filter parameters
filters = extract_filter_params(request)
# Apply filters using helper function
filtered_df = apply_filters_to_dataset(dataset, **filters)
logger.debug(f"Applied filters, resulting in {len(filtered_df)} records")
# Calculate KPIs from filtered data
filtered_kpis = compute_kpis(filtered_df)
# Prepare response data
response_data = {
"total_records": len(filtered_df),
"filtered_data": filtered_df.to_dict('records')[:1000], # Limit to 1000 records for performance
"kpis": filtered_kpis
}
metadata = {
"filters_applied": {k: v for k, v in filters.items() if v},
"original_dataset_size": len(dataset),
"filtered_dataset_size": len(filtered_df),
"data_limit": 1000
}
return jsonify(format_api_response(response_data, message="Filtered data retrieved successfully", metadata=metadata))
except Exception as e:
logger.error(f"Error processing filtered data: {str(e)}")
return jsonify(format_api_response(None, status='error', message="Internal server error")), 500
@app.route("/api/chart-data")
def get_chart_data():
"""Get chart data based on current filters with proper error handling"""
try:
# Get dataset
dataset = get_filtered_dataset()
if dataset is None or dataset.empty:
logger.error("Dataset not available")
return jsonify({"error": "Dataset not loaded"}), 500
# Extract filter parameters and apply filters
filters = extract_filter_params(request)
filtered_df = apply_filters_to_dataset(dataset, **filters)
logger.debug(f"Applied chart filters, resulting in {len(filtered_df)} records")
# Generate insightful charts instead of basic ones
# Pass filters so we can use full dataset for accurate country counts
charts_data = generate_insightful_charts(filtered_df, filters=filters)
# Add metadata about the analysis
metadata = {
"filters_applied": {k: v for k, v in filters.items() if v},
"dataset_size": len(filtered_df),
"charts_generated": len(charts_data),
"chart_types": list(charts_data.keys())
}
return jsonify(format_api_response(charts_data, message="Insightful charts generated successfully", metadata=metadata))
except Exception as e:
logger.error(f"Error processing chart data: {str(e)}")
return jsonify({"error": "Internal server error"}), 500
@app.route("/api/eda-data")
def get_eda_data():
"""Get EDA analysis data for frontend visualization"""
try:
if EDA_DATA is None:
return jsonify({"error": "EDA data not available"}), 500
# Return structured EDA data
return jsonify({
"summaries": EDA_DATA['summaries'],
"available_tables": list(EDA_DATA['tables'].keys()),
"available_plots": list(EDA_DATA['plot_data'].keys()),
"status": "success"
})
except Exception as e:
logger.error(f"Error serving EDA data: {str(e)}")
return jsonify({"error": "Internal server error"}), 500
@app.route("/api/eda-table/<table_name>")
def get_eda_table(table_name):
"""Get specific EDA table data"""
try:
if EDA_DATA is None:
return jsonify({"error": "EDA data not available"}), 500
table_data = EDA_DATA['tables'].get(table_name)
if table_data is None:
return jsonify({"error": f"Table {table_name} not found"}), 404
# Convert DataFrame to JSON
if hasattr(table_data, 'to_dict'):
return jsonify({
"data": table_data.to_dict('records'),
"columns": table_data.columns.tolist(),
"shape": table_data.shape,
"status": "success"
})
else:
return jsonify({
"data": table_data,
"status": "success"
})
except Exception as e:
logger.error(f"Error serving table {table_name}: {str(e)}")
return jsonify({"error": "Internal server error"}), 500
@app.route("/api/eda-summary/<analysis_name>")
def get_eda_summary(analysis_name):
"""Get specific EDA analysis summary"""
try:
if EDA_DATA is None:
return jsonify({"error": "EDA data not available"}), 500
summary = EDA_DATA['summaries'].get(analysis_name)
if summary is None:
return jsonify({"error": f"Analysis {analysis_name} not found"}), 404
return jsonify({
"summary": summary,
"status": "success"
})
except Exception as e:
logger.error(f"Error serving summary {analysis_name}: {str(e)}")
return jsonify({"error": "Internal server error"}), 500
@app.route("/api/eda-charts")
def get_eda_charts():
"""Get chart data from EDA analysis for frontend visualization"""
try:
if EDA_DATA is None:
return jsonify({"error": "EDA data not available"}), 500
charts_data = {}
# Country distribution from EDA - use full dataset for comprehensive results
try:
# Load the full processed dataset to get all 72 countries
processed_df = EDA_DATA['tables'].get('processed_spotify_dataset')
if processed_df is not None and 'country' in processed_df.columns:
# Get country counts from the full dataset - count unique songs per country
if 'spotify_id' in processed_df.columns:
country_counts = processed_df.groupby('country')['spotify_id'].nunique().sort_values(ascending=False)
else:
# Fallback to row count if spotify_id not available
country_counts = processed_df['country'].dropna().value_counts()
# Get top 20 countries for better visualization
top_countries_data = country_counts.head(20)
countries = top_countries_data.index.tolist()
counts = top_countries_data.values.tolist()
country_data = list(zip(countries, counts))
top_countries = country_data
else:
# Fallback to plot data (top 15 countries)
geo_plot_data = EDA_DATA['plot_data'].get('geographical_analysis', {})
if geo_plot_data and 'country_counts' in geo_plot_data:
country_counts = geo_plot_data['country_counts']
countries = list(country_counts.keys())
counts = list(country_counts.values())
country_data = list(zip(countries, counts))
top_countries = country_data
else:
# Final fallback: use summary data (top 10 countries)
geo_summary = EDA_DATA['summaries'].get('geographical_analysis', {})
top_countries_data = geo_summary.get('top_10_countries', {})
countries = list(top_countries_data.keys())
counts = list(top_countries_data.values())
country_data = list(zip(countries, counts))
top_countries = country_data[:10]
except Exception as e:
logger.error(f"Error processing country data: {str(e)}")
# Fallback to summary data
geo_summary = EDA_DATA['summaries'].get('geographical_analysis', {})
top_countries_data = geo_summary.get('top_10_countries', {})
countries = list(top_countries_data.keys())
counts = list(top_countries_data.values())
country_data = list(zip(countries, counts))
top_countries = country_data[:10]
charts_data['country_distribution'] = {
'labels': [get_country_name(country) for country, _ in top_countries],
'data': [count for _, count in top_countries],
'counts': [count for _, count in top_countries]
}
# Temporal trends from EDA
daily_df = EDA_DATA['tables'].get('daily_song_counts')
if daily_df is not None:
# The CSV only contains counts, not dates - create dates from temporal analysis
temporal_summary = EDA_DATA['summaries'].get('temporal_analysis', {})
start_date = temporal_summary.get('date_range_start', '2023-10-18')
# Parse the start date and create date range
start_date_parsed = pd.to_datetime(start_date)
dates = pd.date_range(start=start_date_parsed, periods=len(daily_df), freq='D').strftime('%Y-%m-%d').tolist()
counts = daily_df.iloc[:, 0].tolist() if len(daily_df.columns) > 0 else []
charts_data['temporal_trends'] = {
'labels': dates,
'data': counts,
'counts': counts
}
# Audio features correlation from EDA
audio_df = EDA_DATA['tables'].get('audio_features_correlation_matrix')
if audio_df is not None:
# Get correlation data for visualization
features = audio_df.columns.tolist() if hasattr(audio_df, 'columns') else []
correlation_data = audio_df.values.tolist() if hasattr(audio_df, 'values') else []
charts_data['audio_features'] = {
'features': features,
'correlation_matrix': correlation_data
}
# Explicitness analysis from EDA