[{"data":1,"prerenderedAt":4763},["ShallowReactive",2],{"navigation":3,"/blog/music-data/maintaining-music-tech-tools-the-sla-dilemma-for-small-teams-post":734,"/blog/music-data/maintaining-music-tech-tools-the-sla-dilemma-for-small-teams-surround":1255,"/blog/music-data/maintaining-music-tech-tools-the-sla-dilemma-for-small-teams-related":1260},[4,70,207,312,721],{"title":5,"path":6,"stem":7,"children":8,"page":69},"Case Study","/blog/case-study","blog/case-study",[9,13,17,21,25,29,33,37,41,45,49,53,57,61,65],{"title":10,"path":11,"stem":12},"Ambistream – Multi-Layer Streaming Platform","/blog/case-study/ambistream-building-a-multi-layer-streaming-platform-from-a-spark-of-an-idea","blog/case-study/ambistream-building-a-multi-layer-streaming-platform-from-a-spark-of-an-idea",{"title":14,"path":15,"stem":16},"Custom Chromecast & AirPlay Casting App","/blog/case-study/chromecast-airplay-casting-app-case-study","blog/case-study/chromecast-airplay-casting-app-case-study",{"title":18,"path":19,"stem":20},"Direct Music Licensing Platform Case Study","/blog/case-study/dlm-music-catalog-case-study","blog/case-study/dlm-music-catalog-case-study",{"title":22,"path":23,"stem":24},"Assembly Instructions App - Galeco Mobile App","/blog/case-study/galeco-mobile-app-case-study","blog/case-study/galeco-mobile-app-case-study",{"title":26,"path":27,"stem":28},"MemoSonic: Building an Audio Memory Game","/blog/case-study/how-we-built-memosonic-accessible-audio-memory-game-flutter","blog/case-study/how-we-built-memosonic-accessible-audio-memory-game-flutter",{"title":30,"path":31,"stem":32},"Loyalty Program App for Shopping Mall","/blog/case-study/loyalty-program-application-case-study","blog/case-study/loyalty-program-application-case-study",{"title":34,"path":35,"stem":36},"Mobile Application for Music Catalogs","/blog/case-study/mobile-app-for-music-catalog","blog/case-study/mobile-app-for-music-catalog",{"title":38,"path":39,"stem":40},"Universal Music Data Parser for 20+ Platforms","/blog/case-study/musicdata-lab-universal-music-data-parser-case-study","blog/case-study/musicdata-lab-universal-music-data-parser-case-study",{"title":42,"path":43,"stem":44},"Panther ML/AI Pricing Recommendation Tool","/blog/case-study/panther-pricing-recommendation-tool-case-study","blog/case-study/panther-pricing-recommendation-tool-case-study",{"title":46,"path":47,"stem":48},"4D Grupa Roofing Wholesalers Platform","/blog/case-study/roofing-wholesalers-website-case-study","blog/case-study/roofing-wholesalers-website-case-study",{"title":50,"path":51,"stem":52},"Talent Alpha HR Frontend Platform","/blog/case-study/talent-alpha-hr-platform-case-study","blog/case-study/talent-alpha-hr-platform-case-study",{"title":54,"path":55,"stem":56},"Ticketing & Events Platform Development","/blog/case-study/ticketing-events-platform-case-study","blog/case-study/ticketing-events-platform-case-study",{"title":58,"path":59,"stem":60},"Turn Fans into Superfans — Roadie.co","/blog/case-study/turn-fans-into-superfans-roadie-co","blog/case-study/turn-fans-into-superfans-roadie-co",{"title":62,"path":63,"stem":64},"Walkative 2.0 Global Booking Engine","/blog/case-study/walkative-2-booking-platform-case-study","blog/case-study/walkative-2-booking-platform-case-study",{"title":66,"path":67,"stem":68},"Why Merch Is the New Royalty Check, and How Tech Is Closing the Gap","/blog/case-study/why-merch-is-the-new-royalty-check","blog/case-study/why-merch-is-the-new-royalty-check",false,{"title":71,"path":72,"stem":73,"children":74,"page":69},"Music Data","/blog/music-data","blog/music-data",[75,79,83,87,91,95,99,103,107,111,115,119,123,127,131,135,139,143,147,151,155,159,163,167,171,175,179,183,187,191,195,199,203],{"title":76,"path":77,"stem":78},"13 Distributors, 5 File Formats, Zero Standards -The Reality of Music Royalty Data","/blog/music-data/13-distributors-5-file-formats-zero-standards-the-reality-of-music-royalty-data","blog/music-data/13-distributors-5-file-formats-zero-standards-the-reality-of-music-royalty-data",{"title":80,"path":81,"stem":82},"830 Ways to Say Spotify - Normalizing Music Streaming Data","/blog/music-data/830-ways-to-say-spotify-normalizing-music-streaming-data","blog/music-data/830-ways-to-say-spotify-normalizing-music-streaming-data",{"title":84,"path":85,"stem":86},"Why Music Companies Need AI-Powered Analytics (And How We Built One)","/blog/music-data/ai-powered-analytics-dashboard-django-clickhouse-ollama","blog/music-data/ai-powered-analytics-dashboard-django-clickhouse-ollama",{"title":88,"path":89,"stem":90},"AI Rehearsal: Spaced Repetition for Your Musical Ideas","/blog/music-data/ai-rehearsal-spaced-repetition-for-musical-ideas","blog/music-data/ai-rehearsal-spaced-repetition-for-musical-ideas",{"title":92,"path":93,"stem":94},"Audio Project Organization Is a Mess — Here's Why","/blog/music-data/audio-project-organization-mess","blog/music-data/audio-project-organization-mess",{"title":96,"path":97,"stem":98},"Why Audio Search Is Still Broken and How to Fix It with Embeddings","/blog/music-data/audio-search-broken-fix-with-embeddings","blog/music-data/audio-search-broken-fix-with-embeddings",{"title":100,"path":101,"stem":102},"AI Song Structure Analysis: Intro, Verse, Chorus","/blog/music-data/automatic-song-structure-analysis-how-ai-detects-intro-verse-chorus","blog/music-data/automatic-song-structure-analysis-how-ai-detects-intro-verse-chorus",{"title":104,"path":105,"stem":106},"The Broken Feedback Loop in Music Collaboration","/blog/music-data/broken-feedback-loop-music-collaboration","blog/music-data/broken-feedback-loop-music-collaboration",{"title":108,"path":109,"stem":110},"Building a Claude Skill for DDEX Validation: Automate Music Metadata Checks with AI","/blog/music-data/building-a-claude-skill-for-ddex-validation-music-metadata","blog/music-data/building-a-claude-skill-for-ddex-validation-music-metadata",{"title":112,"path":113,"stem":114},"Building a Custom Music Delivery Platform on the Revelator API","/blog/music-data/building-a-custom-music-delivery-platform-on-the-revelator-api","blog/music-data/building-a-custom-music-delivery-platform-on-the-revelator-api",{"title":116,"path":117,"stem":118},"Building a Suno AI Remix App with Nuxt & Firebase","/blog/music-data/building-a-suno-remix-app-with-nuxt-and-firebase","blog/music-data/building-a-suno-remix-app-with-nuxt-and-firebase",{"title":120,"path":121,"stem":122},"C2PA & DDEX: Authenticity Meets Rights in the Age of AI Music","/blog/music-data/c2pa-and-ddex-authenticity-meets-rights-in-the-age-of-ai-music","blog/music-data/c2pa-and-ddex-authenticity-meets-rights-in-the-age-of-ai-music",{"title":124,"path":125,"stem":126},"C2PA in Music: A Claude MCP for Reading Content Provenance","/blog/music-data/c2pa-in-music-mcp","blog/music-data/c2pa-in-music-mcp",{"title":128,"path":129,"stem":130},"Data Modeling in MongoDB Using Design Patterns","/blog/music-data/data-modeling-in-mongodb-with-the-usage-of-design-patterns","blog/music-data/data-modeling-in-mongodb-with-the-usage-of-design-patterns",{"title":132,"path":133,"stem":134},"Office Hours with MusicTech Lab's DDEX Expert","/blog/music-data/ddex-office-hours-musictech","blog/music-data/ddex-office-hours-musictech",{"title":136,"path":137,"stem":138},"DDEX Open Source Projects Review","/blog/music-data/ddex-open-source-projects-review","blog/music-data/ddex-open-source-projects-review",{"title":140,"path":141,"stem":142},"Extracting Data from Ableton .als and .asd Files","/blog/music-data/extracting-data-from-ableton-als-asd-files","blog/music-data/extracting-data-from-ableton-als-asd-files",{"title":144,"path":145,"stem":146},"Maciej Dulski on Sound Connections Podcast","/blog/music-data/from-startups-to-musictech-maciej-dulski-on-sound-connections-podcast","blog/music-data/from-startups-to-musictech-maciej-dulski-on-sound-connections-podcast",{"title":148,"path":149,"stem":150},"How to Transcribe Video to Text Using OpenAI Whisper","/blog/music-data/how-to-transcribe-video-to-text-using-whisper","blog/music-data/how-to-transcribe-video-to-text-using-whisper",{"title":152,"path":153,"stem":154},"Epidemic Sound MCP with Claude for Devs","/blog/music-data/how-to-use-epidemic-sound-mcp-with-claude","blog/music-data/how-to-use-epidemic-sound-mcp-with-claude",{"title":156,"path":157,"stem":158},"Hybrid Database Model in Django for Speed","/blog/music-data/hybrid-database-model-in-django-as-a-performance-booster","blog/music-data/hybrid-database-model-in-django-as-a-performance-booster",{"title":160,"path":161,"stem":162},"Introduction to generating DDEX file using Python","/blog/music-data/introduction-to-generating-ddex-file-using-python","blog/music-data/introduction-to-generating-ddex-file-using-python",{"title":164,"path":165,"stem":166},"Maintaining Music Tech Tools: The SLA Dilemma for Small Teams","/blog/music-data/maintaining-music-tech-tools-the-sla-dilemma-for-small-teams","blog/music-data/maintaining-music-tech-tools-the-sla-dilemma-for-small-teams",{"title":168,"path":169,"stem":170},"Querying Bandcamp Revenue Reports with Natural Language — Meet mtl-bandcamp-mcp","/blog/music-data/mtl-bandcamp-mcp-open-source-revenue-dashboard","blog/music-data/mtl-bandcamp-mcp-open-source-revenue-dashboard",{"title":172,"path":173,"stem":174},"mtl-metadata-mcp: Open Source Audio Metadata Embedding for Claude Code","/blog/music-data/mtl-metadata-mcp-open-source-audio-metadata-embedding","blog/music-data/mtl-metadata-mcp-open-source-audio-metadata-embedding",{"title":176,"path":177,"stem":178},"MusicTech Resources for Builders","/blog/music-data/musictech-resources-curated-insights-for-the-musictech-builders","blog/music-data/musictech-resources-curated-insights-for-the-musictech-builders",{"title":180,"path":181,"stem":182},"Poland's Creative Tech and MusicTech Rise","/blog/music-data/polands-creative-tech-sector-is-on-the-rise-and-musictech-is-part-of-it","blog/music-data/polands-creative-tech-sector-is-on-the-rise-and-musictech-is-part-of-it",{"title":184,"path":185,"stem":186},"Batch ISRC Enrichment That Turns Messy Catalogs Into Clean Data","/blog/music-data/scout-isrc-metadata-enrichment-spotify-musicbrainz","blog/music-data/scout-isrc-metadata-enrichment-spotify-musicbrainz",{"title":188,"path":189,"stem":190},"Music Self-Publishing: The Emuze.me Story","/blog/music-data/self-publishing-in-the-music-industry-a-tale-of-emuze-me","blog/music-data/self-publishing-in-the-music-industry-a-tale-of-emuze-me",{"title":192,"path":193,"stem":194},"Understanding the API First Approach","/blog/music-data/understanding-the-api-first-approach","blog/music-data/understanding-the-api-first-approach",{"title":196,"path":197,"stem":198},"The Voice Memo Graveyard Problem","/blog/music-data/voice-memo-graveyard-problem","blog/music-data/voice-memo-graveyard-problem",{"title":200,"path":201,"stem":202},"Which Database for Music Data? Redshift vs BigQuery vs ClickHouse and When to Use Each","/blog/music-data/which-database-for-music-data-redshift-vs-bigquery-vs-clickhouse","blog/music-data/which-database-for-music-data-redshift-vs-bigquery-vs-clickhouse",{"title":204,"path":205,"stem":206},"Why we decided to use wavesurfer.js","/blog/music-data/why-we-decided-to-use-wavesurfer","blog/music-data/why-we-decided-to-use-wavesurfer",{"title":208,"path":209,"stem":210,"children":211,"page":69},"Newsletter","/blog/newsletter","blog/newsletter",[212,216,220,224,228,232,236,240,244,248,252,256,260,264,268,272,276,280,284,288,292,296,300,304,308],{"title":213,"path":214,"stem":215},"Music Industry Tech Openings (April 2024 Update)","/blog/newsletter/music-industry-tech-openings-april-2024-update","blog/newsletter/music-industry-tech-openings-april-2024-update",{"title":217,"path":218,"stem":219},"Music Industry Tech Openings (April 2025 Update)","/blog/newsletter/music-industry-tech-openings-april-2025-update","blog/newsletter/music-industry-tech-openings-april-2025-update",{"title":221,"path":222,"stem":223},"Music Industry Tech Openings (August 2024 Update)","/blog/newsletter/music-industry-tech-openings-august-2024-update","blog/newsletter/music-industry-tech-openings-august-2024-update",{"title":225,"path":226,"stem":227},"Music Industry Tech Openings (December 2024 Update)","/blog/newsletter/music-industry-tech-openings-december-2024-update","blog/newsletter/music-industry-tech-openings-december-2024-update",{"title":229,"path":230,"stem":231},"Music Industry Tech Openings (February 2025 Update)","/blog/newsletter/music-industry-tech-openings-february-2025-update","blog/newsletter/music-industry-tech-openings-february-2025-update",{"title":233,"path":234,"stem":235},"Music Industry Tech Openings (January 2025 Update)","/blog/newsletter/music-industry-tech-openings-january-2025-update","blog/newsletter/music-industry-tech-openings-january-2025-update",{"title":237,"path":238,"stem":239},"Music Industry Tech Openings (July 2024 Update)","/blog/newsletter/music-industry-tech-openings-july-2024-update","blog/newsletter/music-industry-tech-openings-july-2024-update",{"title":241,"path":242,"stem":243},"Music Industry Tech Openings (June 2024 Update)","/blog/newsletter/music-industry-tech-openings-june-2024-update","blog/newsletter/music-industry-tech-openings-june-2024-update",{"title":245,"path":246,"stem":247},"Music Industry Tech Openings (March 2025 Update)","/blog/newsletter/music-industry-tech-openings-march-2025-update","blog/newsletter/music-industry-tech-openings-march-2025-update",{"title":249,"path":250,"stem":251},"Music Industry Tech Openings (May 2024 Update)","/blog/newsletter/music-industry-tech-openings-may-2024-update","blog/newsletter/music-industry-tech-openings-may-2024-update",{"title":253,"path":254,"stem":255},"Music Industry Tech Openings (May 2025 Update)","/blog/newsletter/music-industry-tech-openings-may-2025","blog/newsletter/music-industry-tech-openings-may-2025",{"title":257,"path":258,"stem":259},"Music Industry Tech Openings (November 2024 Update)","/blog/newsletter/music-industry-tech-openings-november-2024-update","blog/newsletter/music-industry-tech-openings-november-2024-update",{"title":261,"path":262,"stem":263},"Music Industry Tech Openings (October 2024 Update)","/blog/newsletter/music-industry-tech-openings-october-2024-update","blog/newsletter/music-industry-tech-openings-october-2024-update",{"title":265,"path":266,"stem":267},"Music Industry Tech Openings (September 2024 Update)","/blog/newsletter/music-industry-tech-openings-september-2024-update","blog/newsletter/music-industry-tech-openings-september-2024-update",{"title":269,"path":270,"stem":271},"MusicTech Insights #1 by Maciej Dulski","/blog/newsletter/musictech-insights-1-curated-by-maciej-dulski","blog/newsletter/musictech-insights-1-curated-by-maciej-dulski",{"title":273,"path":274,"stem":275},"Provenance, Not Detection, Is the Durable Answer","/blog/newsletter/musictech-insights-10-curated-by-jim-anderson","blog/newsletter/musictech-insights-10-curated-by-jim-anderson",{"title":277,"path":278,"stem":279},"What CMOs can teach us about innovation in uncertain times","/blog/newsletter/musictech-insights-10-curated-by-joanna-kurkowska","blog/newsletter/musictech-insights-10-curated-by-joanna-kurkowska",{"title":281,"path":282,"stem":283},"Feeling the MusicTech Momentum","/blog/newsletter/musictech-insights-2-curated-by-maciej-dulski","blog/newsletter/musictech-insights-2-curated-by-maciej-dulski",{"title":285,"path":286,"stem":287},"AI in Music: Hype, Hope, and a Human Touch","/blog/newsletter/musictech-insights-3-curated-by-drew-thurlow","blog/newsletter/musictech-insights-3-curated-by-drew-thurlow",{"title":289,"path":290,"stem":291},"The Music Metadata Conundrum","/blog/newsletter/musictech-insights-4-curated-by-amanda-schupf","blog/newsletter/musictech-insights-4-curated-by-amanda-schupf",{"title":293,"path":294,"stem":295},"7 Rounds in the First 10 Days of November 2025","/blog/newsletter/musictech-insights-5-curated-by-maciej-dulski","blog/newsletter/musictech-insights-5-curated-by-maciej-dulski",{"title":297,"path":298,"stem":299},"The End of an Era: It's All About to Crash","/blog/newsletter/musictech-insights-6-curated-by-sigurdur-arnason","blog/newsletter/musictech-insights-6-curated-by-sigurdur-arnason",{"title":301,"path":302,"stem":303},"Low-Code Magic Won't Solve MusicTech Reality","/blog/newsletter/musictech-insights-7-curated-by-mariusz-smenzyk","blog/newsletter/musictech-insights-7-curated-by-mariusz-smenzyk",{"title":305,"path":306,"stem":307},"The New Economics of Game Music","/blog/newsletter/musictech-insights-8-curated-by-kenny-vaughan","blog/newsletter/musictech-insights-8-curated-by-kenny-vaughan",{"title":309,"path":310,"stem":311},"Music Business Meets Direct-to-Fan","/blog/newsletter/musictech-insights-9-curated-by-yaw-asamani","blog/newsletter/musictech-insights-9-curated-by-yaw-asamani",{"title":313,"path":314,"stem":315,"children":316,"page":69},"Software Development","/blog/software-development","blog/software-development",[317,321,325,329,333,337,341,345,349,353,357,361,365,369,373,377,381,385,389,393,397,401,405,409,413,417,421,425,429,433,437,441,445,449,453,457,461,465,469,473,477,481,485,489,493,497,501,505,509,513,517,521,525,529,533,537,541,545,549,553,557,561,565,569,573,577,581,585,589,593,597,601,605,609,613,617,621,625,629,633,637,641,645,649,653,657,661,665,669,673,677,681,685,689,693,697,701,705,709,713,717],{"title":318,"path":319,"stem":320},"Benefits of Outsourcing Software Development","/blog/software-development/10-benefits-of-outsourcing-software-development-services","blog/software-development/10-benefits-of-outsourcing-software-development-services",{"title":322,"path":323,"stem":324},"10 Steps to Find the Best MVP Developers","/blog/software-development/10-steps-to-find-the-best-mvp-developers-for-your-startup-idea","blog/software-development/10-steps-to-find-the-best-mvp-developers-for-your-startup-idea",{"title":326,"path":327,"stem":328},"1,200 Looms Later: How Async Video Became My Development Superpower","/blog/software-development/1200-looms-how-async-video-became-our-development-superpower","blog/software-development/1200-looms-how-async-video-became-our-development-superpower",{"title":330,"path":331,"stem":332},"Communication Strategy in Outsourcing Projects","/blog/software-development/5-steps-to-implement-an-effective-communication-strategy-in-outsourcing-software-development-project","blog/software-development/5-steps-to-implement-an-effective-communication-strategy-in-outsourcing-software-development-project",{"title":334,"path":335,"stem":336},"7 Best Practices for Outsourcing Software Development","/blog/software-development/7-best-practices-for-outsourcing-software-development","blog/software-development/7-best-practices-for-outsourcing-software-development",{"title":338,"path":339,"stem":340},"9 Reasons Why Saleor.io Is Best for eCommerce","/blog/software-development/9-reasons-why-the-saleor-io-platform-is-the-best-choice-for-your-ecommerce-website","blog/software-development/9-reasons-why-the-saleor-io-platform-is-the-best-choice-for-your-ecommerce-website",{"title":342,"path":343,"stem":344},"A Look at Bravelab.io’s Clutch 2021 Year In Review","/blog/software-development/a-look-at-bravelab-ios-clutch-2021-year-in-review","blog/software-development/a-look-at-bravelab-ios-clutch-2021-year-in-review",{"title":346,"path":347,"stem":348},"A quick introduction to profit sharing implementation","/blog/software-development/a-quick-introduction-to-profit-sharing-implementation","blog/software-development/a-quick-introduction-to-profit-sharing-implementation",{"title":350,"path":351,"stem":352},"AI Audio Similarity Search: The Future of Sound Library Discovery","/blog/software-development/ai-audio-similarity-search-for-sound-libraries","blog/software-development/ai-audio-similarity-search-for-sound-libraries",{"title":354,"path":355,"stem":356},"Automate Repetitive Tasks for Better Results","/blog/software-development/automate-repetitive-tasks-to-improve-your-business-performance","blog/software-development/automate-repetitive-tasks-to-improve-your-business-performance",{"title":358,"path":359,"stem":360},"Automating Success: The Art of Unified Documentation","/blog/software-development/automating-success-the-art-of-unified-documentation","blog/software-development/automating-success-the-art-of-unified-documentation",{"title":362,"path":363,"stem":364},"Brave 3.0 Website Redesign, Part 2: Solution","/blog/software-development/brave-3-0-how-we-conducted-website-redesign-part-2-solution","blog/software-development/brave-3-0-how-we-conducted-website-redesign-part-2-solution",{"title":366,"path":367,"stem":368},"Brave 3.0, Part 4: Tech Stack and Recap","/blog/software-development/brave-3-0-part-4-technologies-behind-and-final-series-recap","blog/software-development/brave-3-0-part-4-technologies-behind-and-final-series-recap",{"title":370,"path":371,"stem":372},"Brave 3.0 – redesign process part 1. The Challenge","/blog/software-development/brave-3-0-redesign-process-part-1-challenge","blog/software-development/brave-3-0-redesign-process-part-1-challenge",{"title":374,"path":375,"stem":376},"Brave 3.0 – redesign process, part 3. Lesson learned","/blog/software-development/brave-3-0-redesign-process-part-3-lesson-learned","blog/software-development/brave-3-0-redesign-process-part-3-lesson-learned",{"title":378,"path":379,"stem":380},"Bravelab.io: Top Software Developer by Clutch","/blog/software-development/bravelab-io-is-recognized-as-a-top-custom-software-developer-by-clutch","blog/software-development/bravelab-io-is-recognized-as-a-top-custom-software-developer-by-clutch",{"title":382,"path":383,"stem":384},"Bravelab.io: Top Developer in Poland by Clutch","/blog/software-development/bravelab-io-named-top-software-developer-in-poland-by-clutch","blog/software-development/bravelab-io-named-top-software-developer-in-poland-by-clutch",{"title":386,"path":387,"stem":388},"MusicTech Lab Partners with LALAL.AI","/blog/software-development/bravelab-partners-with-the-audio-lalal-ai","blog/software-development/bravelab-partners-with-the-audio-lalal-ai",{"title":390,"path":391,"stem":392},"MusicTech Lab Partners with The Audio Programmer","/blog/software-development/bravelab-partners-with-the-audio-programmer","blog/software-development/bravelab-partners-with-the-audio-programmer",{"title":394,"path":395,"stem":396},"Bravelab's team about productivity","/blog/software-development/bravelabs-team-about-productivity","blog/software-development/bravelabs-team-about-productivity",{"title":398,"path":399,"stem":400},"Braveloper","/blog/software-development/braveloper","blog/software-development/braveloper",{"title":402,"path":403,"stem":404},"Bravely App: Boost Productivity with Django","/blog/software-development/bravely-app-how-to-be-more-productive-with-django-quick","blog/software-development/bravely-app-how-to-be-more-productive-with-django-quick",{"title":406,"path":407,"stem":408},"DIY MIDI Controller for Ableton with Arduino","/blog/software-development/building-a-diy-midi-controller-for-ableton-live-with-arduino","blog/software-development/building-a-diy-midi-controller-for-ableton-live-with-arduino",{"title":410,"path":411,"stem":412},"C2PA in Ableton: Making AI Music Provenance Visible Inside Your DAW","/blog/software-development/c2pa-in-ableton-max-for-live","blog/software-development/c2pa-in-ableton-max-for-live",{"title":414,"path":415,"stem":416},"Change Detection mechanism in Angular","/blog/software-development/change-detection-mechanism-in-angular","blog/software-development/change-detection-mechanism-in-angular",{"title":418,"path":419,"stem":420},"Communication Channels in Remote Work","/blog/software-development/comparison-of-the-communication-channels-in-remote-work","blog/software-development/comparison-of-the-communication-channels-in-remote-work",{"title":422,"path":423,"stem":424},"Connecting Your Max for Live Device to a Cloud API","/blog/software-development/connecting-your-max-for-live-device-to-a-cloud-api","blog/software-development/connecting-your-max-for-live-device-to-a-cloud-api",{"title":426,"path":427,"stem":428},"From Voice Memo to Studio: The Cross-Platform Problem for Creators","/blog/software-development/cross-platform-problem-for-creators","blog/software-development/cross-platform-problem-for-creators",{"title":430,"path":431,"stem":432},"Cultural transformation through the pandemic era","/blog/software-development/cultural-transformation-through-the-pandemic-era","blog/software-development/cultural-transformation-through-the-pandemic-era",{"title":434,"path":435,"stem":436},"D-Commerce Decoded: Cutting Through the Hype","/blog/software-development/d-commerce-decoded-cutting-through-the-hype","blog/software-development/d-commerce-decoded-cutting-through-the-hype",{"title":438,"path":439,"stem":440},"Dev Meeting 002: Intro to DDD","/blog/software-development/dev-meeting-002-introduction-to-domain-driven-design-ddd","blog/software-development/dev-meeting-002-introduction-to-domain-driven-design-ddd",{"title":442,"path":443,"stem":444},"Dev Meeting 003: Web3 Primer","/blog/software-development/dev-meeting-003-web3-primer","blog/software-development/dev-meeting-003-web3-primer",{"title":446,"path":447,"stem":448},"Dev Meeting 004: Introduction to Event Storming","/blog/software-development/dev-meeting-004-introduction-to-event-storming","blog/software-development/dev-meeting-004-introduction-to-event-storming",{"title":450,"path":451,"stem":452},"Dev Meeting 001: Kubernetes is a Framework","/blog/software-development/dev-meeting-kubernetes-is-a-framework","blog/software-development/dev-meeting-kubernetes-is-a-framework",{"title":454,"path":455,"stem":456},"Did You Know? 10 Developer Tips from Real Codebases","/blog/software-development/did-you-know-dev-tips-part-1","blog/software-development/did-you-know-dev-tips-part-1",{"title":458,"path":459,"stem":460},"10 Surprising MusicTech Facts (Part 2)","/blog/software-development/did-you-know-musictech-facts-part-2","blog/software-development/did-you-know-musictech-facts-part-2",{"title":462,"path":463,"stem":464},"Django-cms and GraphQL","/blog/software-development/django-cms-and-graphql","blog/software-development/django-cms-and-graphql",{"title":466,"path":467,"stem":468},"Does Zappa make it super easy?","/blog/software-development/does-zappa-make-it-super-easy","blog/software-development/does-zappa-make-it-super-easy",{"title":470,"path":471,"stem":472},"Establishing cooperation between Netlify and Bravelab","/blog/software-development/establishing-cooperation-between-netlify-and-bravelab","blog/software-development/establishing-cooperation-between-netlify-and-bravelab",{"title":474,"path":475,"stem":476},"Export Ableton Locators to JSON via Max for Live","/blog/software-development/exporting-ableton-live-locators-to-json-with-max-for-live","blog/software-development/exporting-ableton-live-locators-to-json-with-max-for-live",{"title":478,"path":479,"stem":480},"IT Outsourcing: Success and Failure Factors","/blog/software-development/factors-that-contribute-to-the-success-or-failure-of-an-it-outsourcing-project","blog/software-development/factors-that-contribute-to-the-success-or-failure-of-an-it-outsourcing-project",{"title":482,"path":483,"stem":484},"Flutter 2022 Strategy: Analyzing the Roadmap","/blog/software-development/flutter-strategy-for-2022-analyzing-the-new-flutter-roadmap","blog/software-development/flutter-strategy-for-2022-analyzing-the-new-flutter-roadmap",{"title":486,"path":487,"stem":488},"Git Better #1 — Commit Message Convention","/blog/software-development/git-better-1-see-more-with-a-commit-message-convention","blog/software-development/git-better-1-see-more-with-a-commit-message-convention",{"title":490,"path":491,"stem":492},"Hasura in action. How to use it with Django","/blog/software-development/hasura-in-action","blog/software-development/hasura-in-action",{"title":494,"path":495,"stem":496},"Holacracy why and where we are","/blog/software-development/holacracy-why-and-where-we-are","blog/software-development/holacracy-why-and-where-we-are",{"title":498,"path":499,"stem":500},"How does JavaScript work","/blog/software-development/how-does-javascript-work","blog/software-development/how-does-javascript-work",{"title":502,"path":503,"stem":504},"How important is good UX/UI design?","/blog/software-development/how-important-is-good-ux-ui-design","blog/software-development/how-important-is-good-ux-ui-design",{"title":506,"path":507,"stem":508},"How repetitive tasks impact your business","/blog/software-development/how-repetitive-tasks-impact-your-business","blog/software-development/how-repetitive-tasks-impact-your-business",{"title":510,"path":511,"stem":512},"Becoming a Vue.js Dev: Do Paid Trials Work?","/blog/software-development/how-to-become-a-vue-js-developer-and-whether-paid-trials-in-it-work-out","blog/software-development/how-to-become-a-vue-js-developer-and-whether-paid-trials-in-it-work-out",{"title":514,"path":515,"stem":516},"How to Build an MVP in 6 Steps","/blog/software-development/how-to-build-a-minimum-viable-product-mvp-in-6-steps","blog/software-development/how-to-build-a-minimum-viable-product-mvp-in-6-steps",{"title":518,"path":519,"stem":520},"How to conduct workshops for creative industry?","/blog/software-development/how-to-conduct-workshops-for-creative-industry","blog/software-development/how-to-conduct-workshops-for-creative-industry",{"title":522,"path":523,"stem":524},"How to easily create form in Angular","/blog/software-development/how-to-easily-create-form-in-angular","blog/software-development/how-to-easily-create-form-in-angular",{"title":526,"path":527,"stem":528},"How to export orders in Saleor.io to XLSX file","/blog/software-development/how-to-export-orders-in-saleor-io-to-xlsx-file","blog/software-development/how-to-export-orders-in-saleor-io-to-xlsx-file",{"title":530,"path":531,"stem":532},"Handling High Loads on E-Commerce Platforms","/blog/software-development/how-to-handle-high-loads-on-e-commerce-platform-with-ease","blog/software-development/how-to-handle-high-loads-on-e-commerce-platform-with-ease",{"title":534,"path":535,"stem":536},"How to launch Saleor.io shop instance within 40h","/blog/software-development/how-to-launch-saleor-io-shop-instance-within-40h","blog/software-development/how-to-launch-saleor-io-shop-instance-within-40h",{"title":538,"path":539,"stem":540},"First Steps to Build a Business Relationship","/blog/software-development/how-to-make-the-first-step-to-establish-a-business-relationship","blog/software-development/how-to-make-the-first-step-to-establish-a-business-relationship",{"title":542,"path":543,"stem":544},"Multi-Tenant Apps with Django and Saleor.io","/blog/software-development/how-to-manage-tenants-in-the-multitenant-app-based-on-django-tenants-and-saleor-io-platform","blog/software-development/how-to-manage-tenants-in-the-multitenant-app-based-on-django-tenants-and-saleor-io-platform",{"title":546,"path":547,"stem":548},"Notion Backup Tool Built in 3 Days with Python","/blog/software-development/how-we-built-a-notion-backup-tool-in-3-days-with-pythonvue-and-why","blog/software-development/how-we-built-a-notion-backup-tool-in-3-days-with-pythonvue-and-why",{"title":550,"path":551,"stem":552},"Important new features in Python 3.8","/blog/software-development/important-new-features-in-python-3-8","blog/software-development/important-new-features-in-python-3-8",{"title":554,"path":555,"stem":556},"Installing Proxmox on dedicated server from OVH","/blog/software-development/installing-proxmox-on-dedicated-server-from-ovh","blog/software-development/installing-proxmox-on-dedicated-server-from-ovh",{"title":558,"path":559,"stem":560},"Integrating SignNow E-Signatures into Your Django Application","/blog/software-development/integrating-signnow-e-signatures-into-your-django-application","blog/software-development/integrating-signnow-e-signatures-into-your-django-application",{"title":562,"path":563,"stem":564},"Tempus Metronome and GetSongBPM API","/blog/software-development/integrating-tempus-metronome-with-the-getsongbpm-api-what-bpm-really-means-and-how-to-use-it","blog/software-development/integrating-tempus-metronome-with-the-getsongbpm-api-what-bpm-really-means-and-how-to-use-it",{"title":566,"path":567,"stem":568},"Introducing MusicTech Poland","/blog/software-development/introducing-musictech-poland","blog/software-development/introducing-musictech-poland",{"title":570,"path":571,"stem":572},"Vue.js as a Frontend for Saleor.io","/blog/software-development/is-it-possible-to-use-vue-js-as-a-frontend-for-saleor-io-platform","blog/software-development/is-it-possible-to-use-vue-js-as-a-frontend-for-saleor-io-platform",{"title":574,"path":575,"stem":576},"Is your business ready for the cashless era?","/blog/software-development/is-your-business-ready-for-the-cashless-era","blog/software-development/is-your-business-ready-for-the-cashless-era",{"title":578,"path":579,"stem":580},"Is your face ready to buy?","/blog/software-development/is-your-face-ready-to-buy","blog/software-development/is-your-face-ready-to-buy",{"title":582,"path":583,"stem":584},"JS Frameworks: Trends and Opportunities","/blog/software-development/javascript-trending-frameworks-and-market-opportunities","blog/software-development/javascript-trending-frameworks-and-market-opportunities",{"title":586,"path":587,"stem":588},"Kanban Board: Boost Your Team Productivity","/blog/software-development/kanban-board-methodology-hack-your-companys-productivity","blog/software-development/kanban-board-methodology-hack-your-companys-productivity",{"title":590,"path":591,"stem":592},"Verified Human Cert MCP Server: Prove Your Music Is Human-Made, Right from the Terminal","/blog/software-development/mcp-verified-human-cert-open-source","blog/software-development/mcp-verified-human-cert-open-source",{"title":594,"path":595,"stem":596},"Migrating from TravisCI to Github Actions","/blog/software-development/migrating-from-travisci-to-github-actions","blog/software-development/migrating-from-travisci-to-github-actions",{"title":598,"path":599,"stem":600},"MusicTech Lab: Top Software Developer by Clutch","/blog/software-development/musictechlab-is-recognized-as-a-top-custom-software-developer-by-clutch","blog/software-development/musictechlab-is-recognized-as-a-top-custom-software-developer-by-clutch",{"title":602,"path":603,"stem":604},"MusicTech Lab x Verified Human: Building a Trust Layer for Human-Made Music","/blog/software-development/musictechlab_blog_verified_human_partnership","blog/software-development/musictechlab_blog_verified_human_partnership",{"title":606,"path":607,"stem":608},"MusicXML: Standard for Music Notation","/blog/software-development/musicxml-standard-for-music-notation-and-education","blog/software-development/musicxml-standard-for-music-notation-and-education",{"title":610,"path":611,"stem":612},"Only a few books but dozens of ideas","/blog/software-development/only-a-few-books-but-dozens-of-ideas","blog/software-development/only-a-few-books-but-dozens-of-ideas",{"title":614,"path":615,"stem":616},"Overdue Invoices and Issue Tracker Integration","/blog/software-development/overdue-invoices-integration-with-the-issue-tracking-system","blog/software-development/overdue-invoices-integration-with-the-issue-tracking-system",{"title":618,"path":619,"stem":620},"Performing SAML SSO using JWT in Django","/blog/software-development/performing-saml-sso-using-jwt-in-django","blog/software-development/performing-saml-sso-using-jwt-in-django",{"title":622,"path":623,"stem":624},"Progressive Web Apps for Mobile Development","/blog/software-development/progressive-web-apps-a-new-way-of-creating-mobile-application","blog/software-development/progressive-web-apps-a-new-way-of-creating-mobile-application",{"title":626,"path":627,"stem":628},"Recruitment System: Gmail, Jira, and CRM","/blog/software-development/recruitment-system-integrating-gmail-bravely-jira-slack-and-copper-crm","blog/software-development/recruitment-system-integrating-gmail-bravely-jira-slack-and-copper-crm",{"title":630,"path":631,"stem":632},"Scratch Me: Chrome Extension for Leads","/blog/software-development/scratch-me-a-simple-chrome-extension-which-will-increase-your-productivity","blog/software-development/scratch-me-a-simple-chrome-extension-which-will-increase-your-productivity",{"title":634,"path":635,"stem":636},"Scratch Me – integration with the Copper CRM","/blog/software-development/scratch-me-integration-with-the-copper-crm","blog/software-development/scratch-me-integration-with-the-copper-crm",{"title":638,"path":639,"stem":640},"SignNow MCP Server: E-Signatures Straight from Claude Code","/blog/software-development/signnow-mcp-server-e-signatures-from-claude-code","blog/software-development/signnow-mcp-server-e-signatures-from-claude-code",{"title":642,"path":643,"stem":644},"Music Industry Tech Openings (March 2024 Update)","/blog/software-development/technical-job-opportunities-in-the-music-industry","blog/software-development/technical-job-opportunities-in-the-music-industry",{"title":646,"path":647,"stem":648},"Thanks app – a Management 3.0 solution","/blog/software-development/thanks-app-a-management-3-0-solution","blog/software-development/thanks-app-a-management-3-0-solution",{"title":650,"path":651,"stem":652},"Colonial Pipeline Case: 7 Security Reminders","/blog/software-development/the-case-of-colonial-pipeline-and-7-security-reminders","blog/software-development/the-case-of-colonial-pipeline-and-7-security-reminders",{"title":654,"path":655,"stem":656},"The Evolution and Future of E-commerce Platforms","/blog/software-development/the-evolution-and-future-of-e-commerce-platforms","blog/software-development/the-evolution-and-future-of-e-commerce-platforms",{"title":658,"path":659,"stem":660},"The Gender Gap in the Tech Industry","/blog/software-development/the-gender-gap-in-the-tech-industry","blog/software-development/the-gender-gap-in-the-tech-industry",{"title":662,"path":663,"stem":664},"First Attempt to Implement 4DX at Bravelab.io","/blog/software-development/the-very-first-attempt-to-implement-4dx-in-bravelab-io","blog/software-development/the-very-first-attempt-to-implement-4dx-in-bravelab-io",{"title":666,"path":667,"stem":668},"The WTF Scale: IT Project Complexity","/blog/software-development/the-wtf-programming-scale-measuring-it-project-complexity","blog/software-development/the-wtf-programming-scale-measuring-it-project-complexity",{"title":670,"path":671,"stem":672},"Top 10 articles through the eyes of our developers","/blog/software-development/top-10-articles-through-the-eyes-of-our-developers","blog/software-development/top-10-articles-through-the-eyes-of-our-developers",{"title":674,"path":675,"stem":676},"Top 6 apps made with Flutter","/blog/software-development/top-6-apps-made-with-flutter","blog/software-development/top-6-apps-made-with-flutter",{"title":678,"path":679,"stem":680},"Uber 101: How Uber Made It to the Top","/blog/software-development/uber-101-how-this-ride-sharing-behemoth-made-it-to-the-top","blog/software-development/uber-101-how-this-ride-sharing-behemoth-made-it-to-the-top",{"title":682,"path":683,"stem":684},"MusicTech Lab Partners with Music Glue","/blog/software-development/unifying-artists-and-audiences-exploring-music-glue","blog/software-development/unifying-artists-and-audiences-exploring-music-glue",{"title":686,"path":687,"stem":688},"Why AI Will Defeat Traditional HR","/blog/software-development/warning-why-artificial-intelligence-will-defeat-traditional-hr","blog/software-development/warning-why-artificial-intelligence-will-defeat-traditional-hr",{"title":690,"path":691,"stem":692},"What is a Discovery Document?","/blog/software-development/what-is-discovery-document","blog/software-development/what-is-discovery-document",{"title":694,"path":695,"stem":696},"What is Flutter, and Why is it Worth Considering?","/blog/software-development/what-is-flutter-and-why-is-it-worth-considering","blog/software-development/what-is-flutter-and-why-is-it-worth-considering",{"title":698,"path":699,"stem":700},"What is a Watermarked Song?","/blog/software-development/what-is-watermarked-song","blog/software-development/what-is-watermarked-song",{"title":702,"path":703,"stem":704},"Choosing a Frontend Framework for the Web","/blog/software-development/which-framework-should-you-choose-for-the-frontend-web-platform-development","blog/software-development/which-framework-should-you-choose-for-the-frontend-web-platform-development",{"title":706,"path":707,"stem":708},"Why DAWs Are the Wrong Tool for Starting a Song","/blog/software-development/why-daws-wrong-tool-for-starting-song","blog/software-development/why-daws-wrong-tool-for-starting-song",{"title":710,"path":711,"stem":712},"Why the Programming World Loves Python","/blog/software-development/why-the-programming-world-loves-python","blog/software-development/why-the-programming-world-loves-python",{"title":714,"path":715,"stem":716},"Why We Don't Build Chat From Scratch (And Neither Should You)","/blog/software-development/why-we-dont-build-chat-from-scratch","blog/software-development/why-we-dont-build-chat-from-scratch",{"title":718,"path":719,"stem":720},"Why we use Sanity.io","/blog/software-development/why-we-use-sanity-io","blog/software-development/why-we-use-sanity-io",{"title":722,"path":723,"stem":724,"children":725,"page":69},"Sportstech","/blog/sportstech","blog/sportstech",[726,730],{"title":727,"path":728,"stem":729},"BeatBuddy Replay: Video Analysis App Challenges","/blog/sportstech/beatbuddy-replay-video-analysis-app-for-swimmers-flutter","blog/sportstech/beatbuddy-replay-video-analysis-app-for-swimmers-flutter",{"title":731,"path":732,"stem":733},"How to Create a Watch Face App for Garmin Watch","/blog/sportstech/how-to-create-watch-face-app-for-garmin-watch","blog/sportstech/how-to-create-watch-face-app-for-garmin-watch",{"id":735,"title":164,"authors":736,"badge":742,"body":744,"category":1216,"client":1217,"date":1218,"description":1219,"extension":1220,"faq":1221,"featured":1231,"featuredOrder":1211,"hidden":69,"image":1232,"keyTakeaways":1234,"meta":1247,"navigation":1231,"path":165,"seo":1248,"status":1217,"stem":166,"tags":1251,"teaser":1217,"__hash__":1254},"posts/blog/music-data/maintaining-music-tech-tools-the-sla-dilemma-for-small-teams.md",[737],{"name":738,"to":739,"avatar":740},"Mariusz Smenżyk","https://www.linkedin.com/in/mariusz-smenzyk/",{"src":741},"/images/people/mariusz-smenzyk2.webp",{"label":5,"color":743},"#f59e0b",{"type":745,"value":746,"toc":1199},"minimark",[747,751,754,757,762,765,768,771,777,781,784,787,854,860,864,867,945,951,954,961,965,972,978,981,1000,1003,1007,1010,1013,1042,1045,1051,1055,1060,1071,1076,1080,1086,1089,1093,1096,1101,1105,1108,1178,1182,1188,1191,1194],[748,749,750],"p",{},"We built a streaming analytics platform for an independent European label. It aggregated royalty records from multiple distributors into a single searchable dashboard. The label loved it. Their head of operations said it \"completely changed how we handle reporting.\" The tool was stable, fast, and exactly what they needed.",[748,752,753],{},"And then nobody maintained it.",[748,755,756],{},"This is a story about what happens next, and what we learned about pricing maintenance for niche music tech tools.",[758,759,761],"h2",{"id":760},"the-tool-that-worked-too-well","The Tool That Worked Too Well",[748,763,764],{},"In early 2025, we delivered a custom streaming data platform to a well-established independent label. The stack was a web application backed by a search engine optimized for analytical queries, hosted on a managed cloud provider.",[748,766,767],{},"The platform replaced a workflow that previously took days of manual spreadsheet work. It let their team filter, aggregate, and export royalty data across all their distribution partners from a single interface.",[748,769,770],{},"By mid-2025, both the data manager and the head of operations were using it regularly. The head of operations specifically praised how much time it saved during quarterly reporting.",[772,773,774],"note",{},[748,775,776],{},"This is the paradox: the better a tool works, the less visible its maintenance needs become. When everything runs smoothly, \"maintenance\" feels like paying for nothing.",[758,778,780],{"id":779},"the-negotiation-spiral","The Negotiation Spiral",[748,782,783],{},"With our lead developer becoming less available, my business partner reached out to the label with a proposal: set aside a few hours each month for a dedicated developer who would monitor the system and handle issues proactively.",[748,785,786],{},"What followed was a months-long negotiation that perfectly illustrates the gap between how builders and clients think about software maintenance.",[788,789,796,818,832,842],"div",{"className":790},[791,792,793,794,795],"grid","grid-cols-1","md:grid-cols-2","gap-6","my-8",[797,798,801,809,815],"spotlight-card",{"icon":799,"title":800},"i-lucide-file-text","Round 1: The Initial Proposal",[748,802,803,804,808],{},"We proposed a ",[805,806,807],"strong",{},"monthly retainer",", a small block of hours from a developer who already knew the system. Time logged, extras billed proportionally.",[748,810,811],{},[812,813,814],"em",{},"\"We aren't sure that we'll need that many hours of help a month. Some of the problems we've had recently have been more with the files we've been supplied, rather than the tool itself.\"",[748,816,817],{},"The data manager asked about an ad-hoc rate instead.",[797,819,822,829],{"icon":820,"title":821},"i-lucide-handshake","Round 2: Meeting in the Middle",[748,823,824,825,828],{},"We adjusted. Instead of a monthly retainer, we offered ",[805,826,827],{},"a prepaid hour bank",", usable anytime over a full year. Essentially pay-as-you-go with a small upfront commitment.",[748,830,831],{},"The data manager countered with fewer hours and a request that any unused time roll over indefinitely.",[797,833,836,839],{"icon":834,"title":835},"i-lucide-shield-off","Round 3: The Stalemate",[748,837,838],{},"We came down further, offering flexible options with shorter commitment periods but no rollover. We explained that an open-ended rollover creates obligations that hang indefinitely, especially for a system that rarely needs help.",[748,840,841],{},"The data manager countered again with fewer hours and rollover. We couldn't go below our minimum: the smallest package that justified onboarding a new developer onto the project.",[797,843,846,851],{"icon":844,"title":845},"i-lucide-door-open","The Walk-Away",[748,847,848],{},[812,849,850],{},"\"We aren't sure that we'll need that level of support, so we think it may be best if we try and find someone else who can provide support in a more ad-hoc way.\"",[748,852,853],{},"They decided to look for a third-party maintainer. We offered to help with the handover.",[855,856,857],"warning",{},[748,858,859],{},"When a client walks away from a maintenance agreement, neither side is wrong. The client sees a stable system and doesn't want to pay for peace of mind. The builder sees accumulated technical debt and knows that \"stable\" is temporary without active care.",[758,861,863],{"id":862},"the-monthly-crashes","The Monthly Crashes",[748,865,866],{},"Here's the timeline of what happened after the agreement fell through:",[868,869,870,886],"table",{},[871,872,873],"thead",{},[874,875,876,880,883],"tr",{},[877,878,879],"th",{},"Month",[877,881,882],{},"Issue",[877,884,885],{},"Resolution",[887,888,889,903,916,929],"tbody",{},[874,890,891,897,900],{},[892,893,894],"td",{},[805,895,896],{},"Month 1",[892,898,899],{},"Uploader stops working",[892,901,902],{},"We restarted servers",[874,904,905,910,913],{},[892,906,907],{},[805,908,909],{},"Month 2",[892,911,912],{},"Importer breaks: UI shows success but nothing processes",[892,914,915],{},"Emergency fix",[874,917,918,923,926],{},[892,919,920],{},[805,921,922],{},"Month 3",[892,924,925],{},"Uploader down again",[892,927,928],{},"Restart; Data manager asks for technical details to find a third party",[874,930,931,936,942],{},[892,932,933],{},[805,934,935],{},"Month 4",[892,937,938,939],{},"Uploader down ",[812,940,941],{},"again",[892,943,944],{},"Root cause found: security exploit on exposed service",[748,946,947,948],{},"After four rounds, their data manager wrote: ",[812,949,950],{},"\"This does seem to happen every month now. Maybe if it's easy enough you can send me instructions on how to re-start the tool?\"",[748,952,953],{},"They were still searching for someone to take over ad-hoc maintenance. Months later, no one had been found.",[748,955,956],{},[957,958],"img",{"alt":959,"src":960},"Server rack in dark room, unmaintained systems quietly accumulating risk","/images/blog/musictechlab_blog_sla-dilemma-server-rack.webp",[758,962,964],{"id":963},"the-security-debt-nobody-saw","The Security Debt Nobody Saw",[748,966,967,968,971],{},"The fourth crash revealed something more serious than a simple restart issue. When we investigated, we found that ",[805,969,970],{},"a core infrastructure service had crashed due to a security exploit attempt",". The service was exposed to the public internet, running an outdated version with insufficient access controls.",[973,974,975],"tip",{},[748,976,977],{},"This is what \"no maintenance\" actually looks like. It's not just restarts. It's unpatched services, exposed ports, and outdated dependencies quietly accumulating risk until something breaks or, worse, gets exploited.",[748,979,980],{},"The fix required:",[982,983,984,988,991,994,997],"ul",{},[985,986,987],"li",{},"Binding the service to localhost only",[985,989,990],{},"Rotating credentials",[985,992,993],{},"Locking down external access",[985,995,996],{},"Upgrading to the latest stable version",[985,998,999],{},"Applying security patches",[748,1001,1002],{},"None of this would have been caught by \"just restarting the tool.\" And none of it would have been needed if someone had been proactively monitoring the system.",[758,1004,1006],{"id":1005},"the-just-restart-it-trap","The \"Just Restart It\" Trap",[748,1008,1009],{},"The data manager's request for restart instructions was perfectly logical. The symptom was always the same: uploader or importer stops working. The fix appeared to be a simple service restart. Why not just do it yourself?",[748,1011,1012],{},"Because restarting masks the root cause. In this case:",[982,1014,1015,1022,1028,1035],{},[985,1016,1017,1018,1021],{},"The ",[805,1019,1020],{},"uploader"," crashed because a backend service crashed",[985,1023,1017,1024,1027],{},[805,1025,1026],{},"backend service"," crashed because of an exploit attempt on an exposed port",[985,1029,1030,1031,1034],{},"The port was ",[805,1032,1033],{},"exposed"," because no one had hardened the configuration after deployment",[985,1036,1037,1038,1041],{},"The configuration was ",[805,1039,1040],{},"unhardened"," because there was no maintenance agreement",[748,1043,1044],{},"Each restart bought a month. Each month, the underlying problem grew worse.",[748,1046,1047],{},[957,1048],{"alt":1049,"src":1050},"Man holding head in frustration at desk with laptop, the reality of searching for a third-party maintainer","/images/blog/musictechlab_blog_sla-dilemma-frustration.webp",[758,1052,1054],{"id":1053},"what-we-learned","What We Learned",[1056,1057,1059],"h3",{"id":1058},"for-builders-price-for-availability-not-just-hours","For builders: price for availability, not just hours",[748,1061,1062,1063,1066,1067,1070],{},"The biggest mistake we made was framing the discussion around ",[812,1064,1065],{},"hours of work",". When the system is stable, hours feel abstract. What the client actually needs is ",[805,1068,1069],{},"availability",": someone who picks up the phone (or email) when the importer breaks on the day they need to run monthly figures.",[772,1072,1073],{},[748,1074,1075],{},"If we did this again, we would frame it differently: \"For X per month, you get a named contact who monitors your system, responds within 24 hours, and handles up to Y requests. No unused hours to argue about.\"",[1056,1077,1079],{"id":1078},"for-clients-maintenance-is-insurance-not-a-service","For clients: maintenance is insurance, not a service",[748,1081,1082,1083],{},"The label's reasoning was sound: ",[812,1084,1085],{},"\"We have relatively few problems with the tool, outside of this server restart issue which comes up fairly often but seems to be very quick to fix.\"",[748,1087,1088],{},"But that's exactly how insurance works. The claim is rare and the resolution is quick, until it isn't. The exploit could have resulted in data loss. The monthly crashes disrupted their workflow at the worst possible time (when royalty reports were due).",[1056,1090,1092],{"id":1091},"for-everyone-the-handover-gap-is-real","For everyone: the handover gap is real",[748,1094,1095],{},"The label spent months looking for a third-party maintainer. When they asked about the stack, we shared the full technical details. Simple enough on paper. But finding someone willing to take on ad-hoc maintenance of a system they didn't build, for a client they have no relationship with, at an unpredictable cadence, is genuinely hard.",[855,1097,1098],{},[748,1099,1100],{},"The original builder is almost always the cheapest and fastest option for maintenance. They know the codebase, the infrastructure, and the client's workflow. Every handover involves a ramp-up period where the new maintainer is slower, more expensive, and more likely to introduce regressions.",[758,1102,1104],{"id":1103},"a-framework-for-music-tech-maintenance-agreements","A Framework for Music Tech Maintenance Agreements",[748,1106,1107],{},"Based on this experience and others, here's what we now recommend:",[788,1109,1112,1132,1155],{"className":1110},[791,792,1111,794,795],"md:grid-cols-3",[797,1113,1116,1127],{"icon":1114,"title":1115},"i-lucide-eye","Tier 1: Monitoring Only",[982,1117,1118,1121,1124],{},[985,1119,1120],{},"Automated uptime monitoring with alerts",[985,1122,1123],{},"Quarterly security patch review",[985,1125,1126],{},"Email response within 48 hours",[748,1128,1129],{},[812,1130,1131],{},"Best for: stable tools with minimal user interaction",[797,1133,1136,1150],{"icon":1134,"title":1135},"i-lucide-life-buoy","Tier 2: Reactive Support",[982,1137,1138,1141,1144,1147],{},[985,1139,1140],{},"Everything in Tier 1",[985,1142,1143],{},"Named developer contact",[985,1145,1146],{},"Response within 24 hours on business days",[985,1148,1149],{},"Small prepaid hour bank (5-10 hours/year)",[748,1151,1152],{},[812,1153,1154],{},"Best for: tools used regularly but not business-critical daily",[797,1156,1159,1173],{"icon":1157,"title":1158},"i-lucide-shield-check","Tier 3: Proactive Maintenance",[982,1160,1161,1164,1167,1170],{},[985,1162,1163],{},"Everything in Tier 2",[985,1165,1166],{},"Monthly health checks (logs, disk space, dependencies)",[985,1168,1169],{},"Proactive security patches and version upgrades",[985,1171,1172],{},"Response within 4 hours on business days",[748,1174,1175],{},[812,1176,1177],{},"Best for: tools that are part of monthly business operations",[758,1179,1181],{"id":1180},"the-ending-so-far","The Ending (So Far)",[748,1183,1184,1185],{},"After the security incident, their data manager acknowledged that price had been the blocker all along. He opened the door to a new conversation: ",[812,1186,1187],{},"\"Happy to chat about that again if you think you can offer something more flexible given the low amount of maintenance that's needed.\"",[748,1189,1190],{},"We started talking again. This time, both sides had a much clearer picture of what \"low amount of maintenance\" actually meant, and what it cost when nobody did it.",[1192,1193],"hr",{},[748,1195,1196],{},[812,1197,1198],{},"Building a custom music data tool? Think about maintenance before you ship. The best time to set up a support agreement is during development, when both sides understand the system and the stakes. The second-best time is before the first crash.",{"title":1200,"searchDepth":1201,"depth":1201,"links":1202},"",2,[1203,1204,1205,1206,1207,1208,1214,1215],{"id":760,"depth":1201,"text":761},{"id":779,"depth":1201,"text":780},{"id":862,"depth":1201,"text":863},{"id":963,"depth":1201,"text":964},{"id":1005,"depth":1201,"text":1006},{"id":1053,"depth":1201,"text":1054,"children":1209},[1210,1212,1213],{"id":1058,"depth":1211,"text":1059},3,{"id":1078,"depth":1211,"text":1079},{"id":1091,"depth":1211,"text":1092},{"id":1103,"depth":1201,"text":1104},{"id":1180,"depth":1201,"text":1181},"music-data",null,"2026-02-24T00:00:00.000Z","What happens when a custom streaming analytics tool works perfectly, until nobody is responsible for keeping it running. A real story from the music industry.","md",[1222,1225,1228],{"question":1223,"answer":1224},"Why do custom music tech tools need ongoing maintenance?","Even stable tools require server restarts, security patches, dependency upgrades, and adapting to new DSP file formats. Without a maintenance agreement, small issues compound into outages.",{"question":1226,"answer":1227},"What is a good SLA model for indie labels with custom tools?","A small prepaid retainer (5-12 hours per year) with a dedicated contact works best. Pay-as-you-go sounds appealing but leaves no one accountable for monitoring or proactive fixes.",{"question":1229,"answer":1230},"How much does music tech tool maintenance typically cost?","Costs vary by complexity, but a small prepaid hour bank or a lightweight monthly retainer is typically the most cost-effective model. Ad-hoc support without an agreement usually ends up costing more due to context-switching and emergency response.",true,{"src":1233},"/images/blog/musictechlab_blog_sla-dilemma-hero.webp",{"enabled":1231,"items":1235},[1236,1239,1242,1244],{"text":1237,"icon":1238},"A tool that crashed monthly had an exposed service exploited due to zero maintenance.","i-lucide-alert-triangle",{"text":1240,"icon":1241},"The original builder is almost always the cheapest and fastest option for ongoing support.","i-lucide-users",{"text":1243,"icon":820},"Frame maintenance as availability (named contact, SLA) not hours of work.",{"text":1245,"icon":1246},"A small prepaid retainer of 5-12 hours per year prevents compounding technical debt.","i-lucide-clock",{},{"title":1249,"description":1250},"Music Tech Tool Maintenance: The SLA Dilemma | MusicTech Lab","What happens when nobody maintains a custom music data tool? Lessons from a real indie label engagement on SLAs, pricing, and security debt.",[1252,1253],"musictech","devops","qPydmx4k5XgqDRcU2H6KWeVwgdx4wUKMmmZNmJtJl0Q",[1256,1258],{"title":160,"path":161,"stem":162,"description":1257,"children":-1},"Learn what DDEX files are and how to generate them using Python. Covers ERN, DSR, and RIN standards for digital music data exchange in the industry.",{"title":168,"path":169,"stem":170,"description":1259,"children":-1},"An open source MCP server that turns Bandcamp CSV exports into queryable dashboards. Ask about artist splits, fee breakdowns, and top sellers — no spreadsheet needed.",[1261,3184,3865,4572],{"id":1262,"title":84,"authors":1263,"badge":1266,"body":1267,"category":1216,"client":1217,"date":3144,"description":3145,"extension":1220,"faq":3146,"featured":69,"featuredOrder":1217,"hidden":69,"image":3159,"keyTakeaways":3161,"meta":3174,"navigation":1231,"path":85,"seo":3175,"status":1217,"stem":86,"tags":3178,"teaser":1217,"__hash__":3183,"score":1211},"posts/blog/music-data/ai-powered-analytics-dashboard-django-clickhouse-ollama.md",[1264],{"name":738,"to":739,"avatar":1265},{"src":741},{"label":5,"color":743},{"type":745,"value":1268,"toc":3129},[1269,1272,1275,1279,1282,1285,1303,1306,1311,1315,1318,1321,1335,1338,1456,1475,1479,1491,1494,1853,1856,2007,2010,2021,2025,2028,2031,2050,2053,2191,2194,2199,2203,2206,2212,2773,2776,2807,2813,3001,3008,3011,3016,3020,3023,3026,3029,3066,3070,3073,3077,3080,3084,3087,3091,3094,3098,3101,3115,3118,3122,3125],[748,1270,1271],{},"Your data team knows the answer is in the database. Your A&R lead, your finance director, and your label manager do not know how to get it out. This is the gap that costs music companies real money, not in licensing fees or infrastructure, but in decisions delayed, trends missed, and reports that arrive a week too late.",[748,1273,1274],{},"We built an AI-powered analytics dashboard to close that gap. It sits inside MusicData Lab, our royalty analytics platform, and it lets anyone with access type a question in plain English and get a chart back in seconds.",[758,1276,1278],{"id":1277},"the-problem-everybody-talks-about-at-conferences","The problem everybody talks about at conferences",[748,1280,1281],{},"The music industry has a data access problem. Not a data collection problem. Labels and distributors already have millions of rows of streaming data, royalty reports, and territorial breakdowns sitting in their databases. The bottleneck is getting from \"I need to know which retailers drove revenue last quarter\" to an actual answer.",[748,1283,1284],{},"Today, that journey typically looks like this:",[1286,1287,1288,1291,1294,1297,1300],"ol",{},[985,1289,1290],{},"A business stakeholder writes an email or Slack message to the data team",[985,1292,1293],{},"The data team interprets the request, writes a SQL query, exports the results",[985,1295,1296],{},"Someone pastes the numbers into a spreadsheet and builds a chart",[985,1298,1299],{},"The chart goes back to the stakeholder, who asks a follow-up question",[985,1301,1302],{},"Repeat from step 1",[748,1304,1305],{},"This loop can take hours, sometimes days. Multiply it across every label, every territory, every reporting period, and you start to see the scale of the problem.",[772,1307,1308],{},[748,1309,1310],{},"A 2024 IFPI report noted that the global recorded music market generated $28.6 billion in revenue, with streaming accounting for 67% of that. The volume of data behind those numbers is staggering, and growing every quarter.",[758,1312,1314],{"id":1313},"what-if-business-users-could-just-ask","What if business users could just ask?",[748,1316,1317],{},"That was the design question behind our AI Dashboard. Instead of routing every data request through a technical team, what if the platform could understand a question like \"top 5 artists from the US by income\" and return a bar chart?",[748,1319,1320],{},"The workflow is simple:",[1286,1322,1323,1326,1329,1332],{},[985,1324,1325],{},"A user types a question in the chat interface",[985,1327,1328],{},"An LLM translates the question into a ClickHouse SQL query",[985,1330,1331],{},"The query runs against the analytics database (read-only, with safety guardrails)",[985,1333,1334],{},"Results come back as an interactive chart and a data table",[748,1336,1337],{},"No SQL knowledge required. No waiting for the data team. No spreadsheets.",[1339,1340,1344],"pre",{"className":1341,"code":1342,"language":1343,"meta":1200,"style":1200},"language-mermaid shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","sequenceDiagram\n    participant U as User\n    participant UI as Chat UI\n    participant LLM as LLM (Ollama/Claude)\n    participant SG as SQL Guard\n    participant CH as ClickHouse\n    participant CB as Chart Builder\n\n    U->>UI: \"Top 5 artists by income\"\n    UI->>LLM: System prompt + question\n    LLM-->>UI: Generated SQL query\n    UI->>SG: Validate SQL\n    SG-->>UI: Sanitised query (SELECT only, LIMIT enforced)\n    UI->>CH: Execute (read-only, 10s timeout)\n    CH-->>UI: Result rows + columns\n    UI->>CB: Build chart config\n    CB-->>UI: Chart.js config (type, labels, datasets)\n    UI-->>U: Interactive chart + data table\n","mermaid",[1345,1346,1347,1356,1361,1366,1372,1378,1384,1390,1396,1402,1408,1414,1420,1426,1432,1438,1444,1450],"code",{"__ignoreMap":1200},[1348,1349,1352],"span",{"class":1350,"line":1351},"line",1,[1348,1353,1355],{"class":1354},"sTEyZ","sequenceDiagram\n",[1348,1357,1358],{"class":1350,"line":1201},[1348,1359,1360],{"class":1354},"    participant U as User\n",[1348,1362,1363],{"class":1350,"line":1211},[1348,1364,1365],{"class":1354},"    participant UI as Chat UI\n",[1348,1367,1369],{"class":1350,"line":1368},4,[1348,1370,1371],{"class":1354},"    participant LLM as LLM (Ollama/Claude)\n",[1348,1373,1375],{"class":1350,"line":1374},5,[1348,1376,1377],{"class":1354},"    participant SG as SQL Guard\n",[1348,1379,1381],{"class":1350,"line":1380},6,[1348,1382,1383],{"class":1354},"    participant CH as ClickHouse\n",[1348,1385,1387],{"class":1350,"line":1386},7,[1348,1388,1389],{"class":1354},"    participant CB as Chart Builder\n",[1348,1391,1393],{"class":1350,"line":1392},8,[1348,1394,1395],{"emptyLinePlaceholder":1231},"\n",[1348,1397,1399],{"class":1350,"line":1398},9,[1348,1400,1401],{"class":1354},"    U->>UI: \"Top 5 artists by income\"\n",[1348,1403,1405],{"class":1350,"line":1404},10,[1348,1406,1407],{"class":1354},"    UI->>LLM: System prompt + question\n",[1348,1409,1411],{"class":1350,"line":1410},11,[1348,1412,1413],{"class":1354},"    LLM-->>UI: Generated SQL query\n",[1348,1415,1417],{"class":1350,"line":1416},12,[1348,1418,1419],{"class":1354},"    UI->>SG: Validate SQL\n",[1348,1421,1423],{"class":1350,"line":1422},13,[1348,1424,1425],{"class":1354},"    SG-->>UI: Sanitised query (SELECT only, LIMIT enforced)\n",[1348,1427,1429],{"class":1350,"line":1428},14,[1348,1430,1431],{"class":1354},"    UI->>CH: Execute (read-only, 10s timeout)\n",[1348,1433,1435],{"class":1350,"line":1434},15,[1348,1436,1437],{"class":1354},"    CH-->>UI: Result rows + columns\n",[1348,1439,1441],{"class":1350,"line":1440},16,[1348,1442,1443],{"class":1354},"    UI->>CB: Build chart config\n",[1348,1445,1447],{"class":1350,"line":1446},17,[1348,1448,1449],{"class":1354},"    CB-->>UI: Chart.js config (type, labels, datasets)\n",[1348,1451,1453],{"class":1350,"line":1452},18,[1348,1454,1455],{"class":1354},"    UI-->>U: Interactive chart + data table\n",[788,1457,1460,1465,1470],{"className":1458},[791,792,1111,1459,795],"gap-4",[797,1461],{"description":1462,"icon":1463,"title":1464},"Ask questions in plain English. No SQL, no training, no onboarding friction.","i-lucide-message-square","Natural Language Input",[797,1466],{"description":1467,"icon":1468,"title":1469},"Columnar storage handles millions of streaming records with sub-second query times.","i-lucide-database","ClickHouse Analytics",[797,1471],{"description":1472,"icon":1473,"title":1474},"Results render as interactive charts. Bar, line, and doughnut, selected automatically.","i-lucide-bar-chart-3","Instant Visualization",[758,1476,1478],{"id":1477},"why-this-matters-for-music-companies-specifically","Why this matters for music companies specifically",[748,1480,1481,1482,1486,1487,1490],{},"Music royalty data is uniquely complex. A single label might receive reports from ",[1483,1484,1485],"a",{"href":77},"13 different distributors, each with its own file format",", column naming, and date conventions. Once that data is normalised and loaded into an analytics database, the schema reflects that complexity: dozens of fields covering artists, tracks, retailers, territories, currencies, and time periods. Even the ",[1483,1488,1489],{"href":81},"retailer names need normalisation"," before they become queryable.",[748,1492,1493],{},"This is precisely the kind of dataset where AI-assisted querying shines. The system prompt includes the full database schema, domain-specific hints, and few-shot examples that teach the model how to write correct ClickHouse SQL. We build it dynamically from Django model metadata, so the prompt stays in sync with the schema automatically:",[1339,1495,1500],{"className":1496,"code":1497,"filename":1498,"language":1499,"meta":1200,"style":1200},"language-python shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","def build_system_prompt() -> str:\n    fields = []\n    for field in StreamDataCH._meta.get_fields():\n        fields.append(f\"  - {field.name} ({field.__class__.__name__})\")\n\n    schema_block = \"\\n\".join(fields)\n\n    return (\n        \"You are a SQL analyst for a music streaming analytics platform.\\n\"\n        f\"The database is ClickHouse. There is one table: `{TABLE}`.\\n\"\n        f\"\\n## Schema\\n\\n{schema_block}\\n\"\n        \"\\n## Domain hints\\n\\n\"\n        \"- `final_income` is income converted to the target currency\\n\"\n        \"- `retailer_union` is the normalized retailer name\\n\"\n        \"- Always filter out empty strings when grouping by text fields\\n\"\n        \"\\n## Output rules\\n\\n\"\n        \"1. Output ONLY a single SELECT statement\\n\"\n        \"2. Always include a LIMIT clause (max 100)\\n\"\n        \"3. Never use INSERT, UPDATE, DELETE, DROP, or any DDL\\n\"\n        f\"\\n## Examples\\n\\n{examples_block}\"\n    )\n","prompts.py","python",[1345,1501,1502,1526,1537,1567,1627,1631,1659,1663,1671,1684,1706,1731,1744,1755,1766,1777,1790,1801,1812,1824,1847],{"__ignoreMap":1200},[1348,1503,1504,1508,1512,1516,1519,1523],{"class":1350,"line":1351},[1348,1505,1507],{"class":1506},"spNyl","def",[1348,1509,1511],{"class":1510},"s2Zo4"," build_system_prompt",[1348,1513,1515],{"class":1514},"sMK4o","()",[1348,1517,1518],{"class":1514}," ->",[1348,1520,1522],{"class":1521},"sBMFI"," str",[1348,1524,1525],{"class":1514},":\n",[1348,1527,1528,1531,1534],{"class":1350,"line":1201},[1348,1529,1530],{"class":1354},"    fields ",[1348,1532,1533],{"class":1514},"=",[1348,1535,1536],{"class":1514}," []\n",[1348,1538,1539,1543,1546,1549,1552,1555,1559,1561,1564],{"class":1350,"line":1211},[1348,1540,1542],{"class":1541},"s7zQu","    for",[1348,1544,1545],{"class":1354}," field ",[1348,1547,1548],{"class":1541},"in",[1348,1550,1551],{"class":1354}," StreamDataCH",[1348,1553,1554],{"class":1514},".",[1348,1556,1558],{"class":1557},"swJcz","_meta",[1348,1560,1554],{"class":1514},[1348,1562,1563],{"class":1510},"get_fields",[1348,1565,1566],{"class":1514},"():\n",[1348,1568,1569,1572,1574,1577,1580,1583,1587,1591,1594,1596,1599,1602,1605,1607,1609,1611,1614,1616,1619,1621,1624],{"class":1350,"line":1368},[1348,1570,1571],{"class":1354},"        fields",[1348,1573,1554],{"class":1514},[1348,1575,1576],{"class":1510},"append",[1348,1578,1579],{"class":1514},"(",[1348,1581,1582],{"class":1506},"f",[1348,1584,1586],{"class":1585},"sfazB","\"  - ",[1348,1588,1590],{"class":1589},"sbssI","{",[1348,1592,1593],{"class":1510},"field",[1348,1595,1554],{"class":1514},[1348,1597,1598],{"class":1557},"name",[1348,1600,1601],{"class":1589},"}",[1348,1603,1604],{"class":1585}," (",[1348,1606,1590],{"class":1589},[1348,1608,1593],{"class":1510},[1348,1610,1554],{"class":1514},[1348,1612,1613],{"class":1354},"__class__",[1348,1615,1554],{"class":1514},[1348,1617,1618],{"class":1354},"__name__",[1348,1620,1601],{"class":1589},[1348,1622,1623],{"class":1585},")\"",[1348,1625,1626],{"class":1514},")\n",[1348,1628,1629],{"class":1350,"line":1374},[1348,1630,1395],{"emptyLinePlaceholder":1231},[1348,1632,1633,1636,1638,1641,1644,1647,1649,1652,1654,1657],{"class":1350,"line":1380},[1348,1634,1635],{"class":1354},"    schema_block ",[1348,1637,1533],{"class":1514},[1348,1639,1640],{"class":1514}," \"",[1348,1642,1643],{"class":1354},"\\n",[1348,1645,1646],{"class":1514},"\"",[1348,1648,1554],{"class":1514},[1348,1650,1651],{"class":1510},"join",[1348,1653,1579],{"class":1514},[1348,1655,1656],{"class":1510},"fields",[1348,1658,1626],{"class":1514},[1348,1660,1661],{"class":1350,"line":1386},[1348,1662,1395],{"emptyLinePlaceholder":1231},[1348,1664,1665,1668],{"class":1350,"line":1392},[1348,1666,1667],{"class":1541},"    return",[1348,1669,1670],{"class":1514}," (\n",[1348,1672,1673,1676,1679,1681],{"class":1350,"line":1398},[1348,1674,1675],{"class":1514},"        \"",[1348,1677,1678],{"class":1585},"You are a SQL analyst for a music streaming analytics platform.",[1348,1680,1643],{"class":1354},[1348,1682,1683],{"class":1514},"\"\n",[1348,1685,1686,1689,1692,1694,1697,1699,1702,1704],{"class":1350,"line":1404},[1348,1687,1688],{"class":1506},"        f",[1348,1690,1691],{"class":1585},"\"The database is ClickHouse. There is one table: `",[1348,1693,1590],{"class":1589},[1348,1695,1696],{"class":1354},"TABLE",[1348,1698,1601],{"class":1589},[1348,1700,1701],{"class":1585},"`.",[1348,1703,1643],{"class":1354},[1348,1705,1683],{"class":1585},[1348,1707,1708,1710,1712,1714,1717,1720,1722,1725,1727,1729],{"class":1350,"line":1410},[1348,1709,1688],{"class":1506},[1348,1711,1646],{"class":1585},[1348,1713,1643],{"class":1354},[1348,1715,1716],{"class":1585},"## Schema",[1348,1718,1719],{"class":1354},"\\n\\n",[1348,1721,1590],{"class":1589},[1348,1723,1724],{"class":1354},"schema_block",[1348,1726,1601],{"class":1589},[1348,1728,1643],{"class":1354},[1348,1730,1683],{"class":1585},[1348,1732,1733,1735,1737,1740,1742],{"class":1350,"line":1416},[1348,1734,1675],{"class":1514},[1348,1736,1643],{"class":1354},[1348,1738,1739],{"class":1585},"## Domain hints",[1348,1741,1719],{"class":1354},[1348,1743,1683],{"class":1514},[1348,1745,1746,1748,1751,1753],{"class":1350,"line":1422},[1348,1747,1675],{"class":1514},[1348,1749,1750],{"class":1585},"- `final_income` is income converted to the target currency",[1348,1752,1643],{"class":1354},[1348,1754,1683],{"class":1514},[1348,1756,1757,1759,1762,1764],{"class":1350,"line":1428},[1348,1758,1675],{"class":1514},[1348,1760,1761],{"class":1585},"- `retailer_union` is the normalized retailer name",[1348,1763,1643],{"class":1354},[1348,1765,1683],{"class":1514},[1348,1767,1768,1770,1773,1775],{"class":1350,"line":1434},[1348,1769,1675],{"class":1514},[1348,1771,1772],{"class":1585},"- Always filter out empty strings when grouping by text fields",[1348,1774,1643],{"class":1354},[1348,1776,1683],{"class":1514},[1348,1778,1779,1781,1783,1786,1788],{"class":1350,"line":1440},[1348,1780,1675],{"class":1514},[1348,1782,1643],{"class":1354},[1348,1784,1785],{"class":1585},"## Output rules",[1348,1787,1719],{"class":1354},[1348,1789,1683],{"class":1514},[1348,1791,1792,1794,1797,1799],{"class":1350,"line":1446},[1348,1793,1675],{"class":1514},[1348,1795,1796],{"class":1585},"1. Output ONLY a single SELECT statement",[1348,1798,1643],{"class":1354},[1348,1800,1683],{"class":1514},[1348,1802,1803,1805,1808,1810],{"class":1350,"line":1452},[1348,1804,1675],{"class":1514},[1348,1806,1807],{"class":1585},"2. Always include a LIMIT clause (max 100)",[1348,1809,1643],{"class":1354},[1348,1811,1683],{"class":1514},[1348,1813,1815,1817,1820,1822],{"class":1350,"line":1814},19,[1348,1816,1675],{"class":1514},[1348,1818,1819],{"class":1585},"3. Never use INSERT, UPDATE, DELETE, DROP, or any DDL",[1348,1821,1643],{"class":1354},[1348,1823,1683],{"class":1514},[1348,1825,1827,1829,1831,1833,1836,1838,1840,1843,1845],{"class":1350,"line":1826},20,[1348,1828,1688],{"class":1506},[1348,1830,1646],{"class":1585},[1348,1832,1643],{"class":1354},[1348,1834,1835],{"class":1585},"## Examples",[1348,1837,1719],{"class":1354},[1348,1839,1590],{"class":1589},[1348,1841,1842],{"class":1354},"examples_block",[1348,1844,1601],{"class":1589},[1348,1846,1683],{"class":1585},[1348,1848,1850],{"class":1350,"line":1849},21,[1348,1851,1852],{"class":1514},"    )\n",[748,1854,1855],{},"The few-shot examples are critical. They teach the model patterns like subqueries for hierarchical groupings (\"top 5 artists with their retailers by income\") that a generic LLM would otherwise flatten into a single GROUP BY:",[1339,1857,1861],{"className":1858,"code":1859,"language":1860,"meta":1200,"style":1200},"language-sql shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","-- Example: top 5 artists with their retailers by income\nSELECT artist, retailer_union, sum(final_income) AS total_income\nFROM analytics_streamdatach\nWHERE artist != '' AND retailer_union != ''\n  AND artist IN (\n    SELECT artist FROM analytics_streamdatach\n    WHERE artist != ''\n    GROUP BY artist ORDER BY sum(final_income) DESC LIMIT 5\n  )\nGROUP BY artist, retailer_union\nORDER BY artist, total_income DESC LIMIT 100\n","sql",[1345,1862,1863,1869,1889,1897,1922,1934,1945,1956,1980,1985,1993],{"__ignoreMap":1200},[1348,1864,1865],{"class":1350,"line":1351},[1348,1866,1868],{"class":1867},"sHwdD","-- Example: top 5 artists with their retailers by income\n",[1348,1870,1871,1874,1877,1880,1883,1886],{"class":1350,"line":1201},[1348,1872,1873],{"class":1589},"SELECT",[1348,1875,1876],{"class":1354}," artist, retailer_union, ",[1348,1878,1879],{"class":1510},"sum",[1348,1881,1882],{"class":1354},"(final_income) ",[1348,1884,1885],{"class":1589},"AS",[1348,1887,1888],{"class":1354}," total_income\n",[1348,1890,1891,1894],{"class":1350,"line":1211},[1348,1892,1893],{"class":1589},"FROM",[1348,1895,1896],{"class":1354}," analytics_streamdatach\n",[1348,1898,1899,1902,1905,1908,1911,1914,1917,1919],{"class":1350,"line":1368},[1348,1900,1901],{"class":1589},"WHERE",[1348,1903,1904],{"class":1354}," artist ",[1348,1906,1907],{"class":1514},"!=",[1348,1909,1910],{"class":1514}," ''",[1348,1912,1913],{"class":1589}," AND",[1348,1915,1916],{"class":1354}," retailer_union ",[1348,1918,1907],{"class":1514},[1348,1920,1921],{"class":1514}," ''\n",[1348,1923,1924,1927,1929,1932],{"class":1350,"line":1374},[1348,1925,1926],{"class":1589},"  AND",[1348,1928,1904],{"class":1354},[1348,1930,1931],{"class":1589},"IN",[1348,1933,1670],{"class":1354},[1348,1935,1936,1939,1941,1943],{"class":1350,"line":1380},[1348,1937,1938],{"class":1589},"    SELECT",[1348,1940,1904],{"class":1354},[1348,1942,1893],{"class":1589},[1348,1944,1896],{"class":1354},[1348,1946,1947,1950,1952,1954],{"class":1350,"line":1386},[1348,1948,1949],{"class":1589},"    WHERE",[1348,1951,1904],{"class":1354},[1348,1953,1907],{"class":1514},[1348,1955,1921],{"class":1514},[1348,1957,1958,1961,1963,1966,1969,1971,1974,1977],{"class":1350,"line":1392},[1348,1959,1960],{"class":1589},"    GROUP BY",[1348,1962,1904],{"class":1354},[1348,1964,1965],{"class":1589},"ORDER BY",[1348,1967,1968],{"class":1510}," sum",[1348,1970,1882],{"class":1354},[1348,1972,1973],{"class":1589},"DESC",[1348,1975,1976],{"class":1589}," LIMIT",[1348,1978,1979],{"class":1589}," 5\n",[1348,1981,1982],{"class":1350,"line":1398},[1348,1983,1984],{"class":1354},"  )\n",[1348,1986,1987,1990],{"class":1350,"line":1404},[1348,1988,1989],{"class":1589},"GROUP BY",[1348,1991,1992],{"class":1354}," artist, retailer_union\n",[1348,1994,1995,1997,2000,2002,2004],{"class":1350,"line":1410},[1348,1996,1965],{"class":1589},[1348,1998,1999],{"class":1354}," artist, total_income ",[1348,2001,1973],{"class":1589},[1348,2003,1976],{"class":1589},[1348,2005,2006],{"class":1589}," 100\n",[748,2008,2009],{},"The result is that even someone who has never seen a database can ask:",[982,2011,2012,2015,2018],{},[985,2013,2014],{},"\"Monthly income trend for 2024\" and get a line chart",[985,2016,2017],{},"\"Income by country\" and get a ranked bar chart",[985,2019,2020],{},"\"Top 10 tracks by streams\" and get a sortable table with the data",[758,2022,2024],{"id":2023},"data-privacy-keeping-everything-local","Data privacy: keeping everything local",[748,2026,2027],{},"Here is where most \"AI analytics\" solutions stumble. They require sending your data, or at least your queries, to a third-party API. For a music company handling confidential royalty data, artist earnings, and pre-release catalogue information, that is often a non-starter.",[748,2029,2030],{},"Our architecture solves this with a pluggable LLM design:",[788,2032,2034,2042],{"className":2033},[791,792,793,1459,795],[797,2035,2039],{"description":2036,"icon":2037,"title":2038},"Open-source LLM runtime running in Docker alongside the application. All inference happens on your infrastructure. Zero data exposure.","i-lucide-server","Ollama (Local, Default)",[748,2040,2041],{},"Recommended for production environments handling sensitive royalty data. Runs models like Mistral 7B locally with 4GB of memory.",[797,2043,2047],{"description":2044,"icon":2045,"title":2046},"Anthropic's Claude API for higher-quality SQL generation. Swap in with a single environment variable change.","i-lucide-cloud","Claude API (Optional)",[748,2048,2049],{},"Useful for development, testing, or environments where data sensitivity is lower and query accuracy is the priority.",[748,2051,2052],{},"Switching between providers is a configuration change, not a code change. The factory pattern reads a single setting and returns the right client:",[1339,2054,2057],{"className":1496,"code":2055,"filename":2056,"language":1499,"meta":1200,"style":1200},"def get_llm_provider() -> LLMProvider:\n    provider = getattr(settings, \"AI_DASHBOARD_PROVIDER\", \"ollama\")\n\n    if provider == \"claude\":\n        from .claude import ClaudeProvider\n        return ClaudeProvider()\n\n    from .ollama import OllamaProvider\n    return OllamaProvider()\n","factory.py",[1345,2058,2059,2075,2111,2115,2135,2152,2163,2167,2182],{"__ignoreMap":1200},[1348,2060,2061,2063,2066,2068,2070,2073],{"class":1350,"line":1351},[1348,2062,1507],{"class":1506},[1348,2064,2065],{"class":1510}," get_llm_provider",[1348,2067,1515],{"class":1514},[1348,2069,1518],{"class":1514},[1348,2071,2072],{"class":1354}," LLMProvider",[1348,2074,1525],{"class":1514},[1348,2076,2077,2080,2082,2085,2087,2090,2093,2095,2098,2100,2102,2104,2107,2109],{"class":1350,"line":1201},[1348,2078,2079],{"class":1354},"    provider ",[1348,2081,1533],{"class":1514},[1348,2083,2084],{"class":1510}," getattr",[1348,2086,1579],{"class":1514},[1348,2088,2089],{"class":1510},"settings",[1348,2091,2092],{"class":1514},",",[1348,2094,1640],{"class":1514},[1348,2096,2097],{"class":1585},"AI_DASHBOARD_PROVIDER",[1348,2099,1646],{"class":1514},[1348,2101,2092],{"class":1514},[1348,2103,1640],{"class":1514},[1348,2105,2106],{"class":1585},"ollama",[1348,2108,1646],{"class":1514},[1348,2110,1626],{"class":1514},[1348,2112,2113],{"class":1350,"line":1211},[1348,2114,1395],{"emptyLinePlaceholder":1231},[1348,2116,2117,2120,2123,2126,2128,2131,2133],{"class":1350,"line":1368},[1348,2118,2119],{"class":1541},"    if",[1348,2121,2122],{"class":1354}," provider ",[1348,2124,2125],{"class":1514},"==",[1348,2127,1640],{"class":1514},[1348,2129,2130],{"class":1585},"claude",[1348,2132,1646],{"class":1514},[1348,2134,1525],{"class":1514},[1348,2136,2137,2140,2143,2146,2149],{"class":1350,"line":1374},[1348,2138,2139],{"class":1541},"        from",[1348,2141,2142],{"class":1514}," .",[1348,2144,2145],{"class":1354},"claude ",[1348,2147,2148],{"class":1541},"import",[1348,2150,2151],{"class":1354}," ClaudeProvider\n",[1348,2153,2154,2157,2160],{"class":1350,"line":1380},[1348,2155,2156],{"class":1541},"        return",[1348,2158,2159],{"class":1510}," ClaudeProvider",[1348,2161,2162],{"class":1514},"()\n",[1348,2164,2165],{"class":1350,"line":1386},[1348,2166,1395],{"emptyLinePlaceholder":1231},[1348,2168,2169,2172,2174,2177,2179],{"class":1350,"line":1392},[1348,2170,2171],{"class":1541},"    from",[1348,2173,2142],{"class":1514},[1348,2175,2176],{"class":1354},"ollama ",[1348,2178,2148],{"class":1541},[1348,2180,2181],{"class":1354}," OllamaProvider\n",[1348,2183,2184,2186,2189],{"class":1350,"line":1398},[1348,2185,1667],{"class":1541},[1348,2187,2188],{"class":1510}," OllamaProvider",[1348,2190,2162],{"class":1514},[748,2192,2193],{},"The same application code, the same security layer, the same chart rendering.",[973,2195,2196],{},[748,2197,2198],{},"The pluggable pattern means you can start with Ollama for privacy, evaluate quality, and switch to Claude API for specific use cases without any migration work.",[758,2200,2202],{"id":2201},"security-by-design-not-by-afterthought","Security by design, not by afterthought",[748,2204,2205],{},"Letting an AI write SQL that runs against your production database sounds risky. It is, if you do it naively. Our approach layers multiple security controls:",[748,2207,2208,2211],{},[805,2209,2210],{},"SQL Guard"," validates every query before execution. Here is the core of it:",[1339,2213,2216],{"className":1496,"code":2214,"filename":2215,"language":1499,"meta":1200,"style":1200},"BLOCKED_KEYWORDS = [\n    \"INSERT\", \"UPDATE\", \"DELETE\", \"DROP\", \"ALTER\",\n    \"TRUNCATE\", \"CREATE\", \"GRANT\", \"REVOKE\",\n    \"ATTACH\", \"DETACH\", \"RENAME\", \"OPTIMIZE\", \"KILL\",\n]\n\nclass SQLGuard:\n    @staticmethod\n    def validate(sql: str) -> str:\n        sql = sql.rstrip(\";\").strip()\n\n        if not sql.upper().startswith(\"SELECT\"):\n            raise SQLGuardError(\"Only SELECT queries are allowed\")\n        if \";\" in sql:\n            raise SQLGuardError(\"Multiple statements are not allowed\")\n\n        sql_upper = sql.upper()\n        for keyword in BLOCKED_KEYWORDS:\n            if re.search(rf\"\\b{keyword}\\b\", sql_upper):\n                raise SQLGuardError(f\"Forbidden keyword: {keyword}\")\n\n        # Only allow the analytics table\n        for table in re.findall(r\"(?:FROM|JOIN)\\s+(\\w+)\", sql_upper):\n            if table.lower() != ALLOWED_TABLE:\n                raise SQLGuardError(f\"Table '{table}' is not allowed\")\n\n        # Enforce LIMIT \u003C= 100\n        return _enforce_limit(sql, sql_upper)\n","guard.py",[1345,2217,2218,2228,2277,2315,2362,2367,2371,2381,2389,2416,2448,2452,2484,2503,2520,2537,2541,2556,2571,2609,2633,2637,2643,2696,2719,2744,2749,2755],{"__ignoreMap":1200},[1348,2219,2220,2223,2225],{"class":1350,"line":1351},[1348,2221,2222],{"class":1354},"BLOCKED_KEYWORDS ",[1348,2224,1533],{"class":1514},[1348,2226,2227],{"class":1514}," [\n",[1348,2229,2230,2233,2236,2238,2240,2242,2245,2247,2249,2251,2254,2256,2258,2260,2263,2265,2267,2269,2272,2274],{"class":1350,"line":1201},[1348,2231,2232],{"class":1514},"    \"",[1348,2234,2235],{"class":1585},"INSERT",[1348,2237,1646],{"class":1514},[1348,2239,2092],{"class":1514},[1348,2241,1640],{"class":1514},[1348,2243,2244],{"class":1585},"UPDATE",[1348,2246,1646],{"class":1514},[1348,2248,2092],{"class":1514},[1348,2250,1640],{"class":1514},[1348,2252,2253],{"class":1585},"DELETE",[1348,2255,1646],{"class":1514},[1348,2257,2092],{"class":1514},[1348,2259,1640],{"class":1514},[1348,2261,2262],{"class":1585},"DROP",[1348,2264,1646],{"class":1514},[1348,2266,2092],{"class":1514},[1348,2268,1640],{"class":1514},[1348,2270,2271],{"class":1585},"ALTER",[1348,2273,1646],{"class":1514},[1348,2275,2276],{"class":1514},",\n",[1348,2278,2279,2281,2284,2286,2288,2290,2293,2295,2297,2299,2302,2304,2306,2308,2311,2313],{"class":1350,"line":1211},[1348,2280,2232],{"class":1514},[1348,2282,2283],{"class":1585},"TRUNCATE",[1348,2285,1646],{"class":1514},[1348,2287,2092],{"class":1514},[1348,2289,1640],{"class":1514},[1348,2291,2292],{"class":1585},"CREATE",[1348,2294,1646],{"class":1514},[1348,2296,2092],{"class":1514},[1348,2298,1640],{"class":1514},[1348,2300,2301],{"class":1585},"GRANT",[1348,2303,1646],{"class":1514},[1348,2305,2092],{"class":1514},[1348,2307,1640],{"class":1514},[1348,2309,2310],{"class":1585},"REVOKE",[1348,2312,1646],{"class":1514},[1348,2314,2276],{"class":1514},[1348,2316,2317,2319,2322,2324,2326,2328,2331,2333,2335,2337,2340,2342,2344,2346,2349,2351,2353,2355,2358,2360],{"class":1350,"line":1368},[1348,2318,2232],{"class":1514},[1348,2320,2321],{"class":1585},"ATTACH",[1348,2323,1646],{"class":1514},[1348,2325,2092],{"class":1514},[1348,2327,1640],{"class":1514},[1348,2329,2330],{"class":1585},"DETACH",[1348,2332,1646],{"class":1514},[1348,2334,2092],{"class":1514},[1348,2336,1640],{"class":1514},[1348,2338,2339],{"class":1585},"RENAME",[1348,2341,1646],{"class":1514},[1348,2343,2092],{"class":1514},[1348,2345,1640],{"class":1514},[1348,2347,2348],{"class":1585},"OPTIMIZE",[1348,2350,1646],{"class":1514},[1348,2352,2092],{"class":1514},[1348,2354,1640],{"class":1514},[1348,2356,2357],{"class":1585},"KILL",[1348,2359,1646],{"class":1514},[1348,2361,2276],{"class":1514},[1348,2363,2364],{"class":1350,"line":1374},[1348,2365,2366],{"class":1514},"]\n",[1348,2368,2369],{"class":1350,"line":1380},[1348,2370,1395],{"emptyLinePlaceholder":1231},[1348,2372,2373,2376,2379],{"class":1350,"line":1386},[1348,2374,2375],{"class":1506},"class",[1348,2377,2378],{"class":1521}," SQLGuard",[1348,2380,1525],{"class":1514},[1348,2382,2383,2386],{"class":1350,"line":1392},[1348,2384,2385],{"class":1514},"    @",[1348,2387,2388],{"class":1521},"staticmethod\n",[1348,2390,2391,2394,2397,2399,2402,2405,2407,2410,2412,2414],{"class":1350,"line":1398},[1348,2392,2393],{"class":1506},"    def",[1348,2395,2396],{"class":1510}," validate",[1348,2398,1579],{"class":1514},[1348,2400,1860],{"class":2401},"sHdIc",[1348,2403,2404],{"class":1514},":",[1348,2406,1522],{"class":1521},[1348,2408,2409],{"class":1514},")",[1348,2411,1518],{"class":1514},[1348,2413,1522],{"class":1521},[1348,2415,1525],{"class":1514},[1348,2417,2418,2421,2423,2426,2428,2431,2433,2435,2438,2440,2443,2446],{"class":1350,"line":1404},[1348,2419,2420],{"class":1354},"        sql ",[1348,2422,1533],{"class":1514},[1348,2424,2425],{"class":1354}," sql",[1348,2427,1554],{"class":1514},[1348,2429,2430],{"class":1510},"rstrip",[1348,2432,1579],{"class":1514},[1348,2434,1646],{"class":1514},[1348,2436,2437],{"class":1585},";",[1348,2439,1646],{"class":1514},[1348,2441,2442],{"class":1514},").",[1348,2444,2445],{"class":1510},"strip",[1348,2447,2162],{"class":1514},[1348,2449,2450],{"class":1350,"line":1410},[1348,2451,1395],{"emptyLinePlaceholder":1231},[1348,2453,2454,2457,2460,2462,2464,2467,2470,2473,2475,2477,2479,2481],{"class":1350,"line":1416},[1348,2455,2456],{"class":1541},"        if",[1348,2458,2459],{"class":1514}," not",[1348,2461,2425],{"class":1354},[1348,2463,1554],{"class":1514},[1348,2465,2466],{"class":1510},"upper",[1348,2468,2469],{"class":1514},"().",[1348,2471,2472],{"class":1510},"startswith",[1348,2474,1579],{"class":1514},[1348,2476,1646],{"class":1514},[1348,2478,1873],{"class":1585},[1348,2480,1646],{"class":1514},[1348,2482,2483],{"class":1514},"):\n",[1348,2485,2486,2489,2492,2494,2496,2499,2501],{"class":1350,"line":1422},[1348,2487,2488],{"class":1541},"            raise",[1348,2490,2491],{"class":1510}," SQLGuardError",[1348,2493,1579],{"class":1514},[1348,2495,1646],{"class":1514},[1348,2497,2498],{"class":1585},"Only SELECT queries are allowed",[1348,2500,1646],{"class":1514},[1348,2502,1626],{"class":1514},[1348,2504,2505,2507,2509,2511,2513,2516,2518],{"class":1350,"line":1428},[1348,2506,2456],{"class":1541},[1348,2508,1640],{"class":1514},[1348,2510,2437],{"class":1585},[1348,2512,1646],{"class":1514},[1348,2514,2515],{"class":1514}," in",[1348,2517,2425],{"class":1354},[1348,2519,1525],{"class":1514},[1348,2521,2522,2524,2526,2528,2530,2533,2535],{"class":1350,"line":1434},[1348,2523,2488],{"class":1541},[1348,2525,2491],{"class":1510},[1348,2527,1579],{"class":1514},[1348,2529,1646],{"class":1514},[1348,2531,2532],{"class":1585},"Multiple statements are not allowed",[1348,2534,1646],{"class":1514},[1348,2536,1626],{"class":1514},[1348,2538,2539],{"class":1350,"line":1440},[1348,2540,1395],{"emptyLinePlaceholder":1231},[1348,2542,2543,2546,2548,2550,2552,2554],{"class":1350,"line":1446},[1348,2544,2545],{"class":1354},"        sql_upper ",[1348,2547,1533],{"class":1514},[1348,2549,2425],{"class":1354},[1348,2551,1554],{"class":1514},[1348,2553,2466],{"class":1510},[1348,2555,2162],{"class":1514},[1348,2557,2558,2561,2564,2566,2569],{"class":1350,"line":1452},[1348,2559,2560],{"class":1541},"        for",[1348,2562,2563],{"class":1354}," keyword ",[1348,2565,1548],{"class":1541},[1348,2567,2568],{"class":1354}," BLOCKED_KEYWORDS",[1348,2570,1525],{"class":1514},[1348,2572,2573,2576,2579,2581,2584,2586,2589,2592,2594,2597,2599,2602,2604,2607],{"class":1350,"line":1814},[1348,2574,2575],{"class":1541},"            if",[1348,2577,2578],{"class":1354}," re",[1348,2580,1554],{"class":1514},[1348,2582,2583],{"class":1510},"search",[1348,2585,1579],{"class":1514},[1348,2587,2588],{"class":1506},"rf",[1348,2590,2591],{"class":1585},"\"\\b",[1348,2593,1590],{"class":1589},[1348,2595,2596],{"class":1510},"keyword",[1348,2598,1601],{"class":1589},[1348,2600,2601],{"class":1585},"\\b\"",[1348,2603,2092],{"class":1514},[1348,2605,2606],{"class":1510}," sql_upper",[1348,2608,2483],{"class":1514},[1348,2610,2611,2614,2616,2618,2620,2623,2625,2627,2629,2631],{"class":1350,"line":1826},[1348,2612,2613],{"class":1541},"                raise",[1348,2615,2491],{"class":1510},[1348,2617,1579],{"class":1514},[1348,2619,1582],{"class":1506},[1348,2621,2622],{"class":1585},"\"Forbidden keyword: ",[1348,2624,1590],{"class":1589},[1348,2626,2596],{"class":1510},[1348,2628,1601],{"class":1589},[1348,2630,1646],{"class":1585},[1348,2632,1626],{"class":1514},[1348,2634,2635],{"class":1350,"line":1849},[1348,2636,1395],{"emptyLinePlaceholder":1231},[1348,2638,2640],{"class":1350,"line":2639},22,[1348,2641,2642],{"class":1867},"        # Only allow the analytics table\n",[1348,2644,2646,2648,2651,2653,2655,2657,2660,2662,2665,2668,2670,2673,2676,2678,2681,2684,2687,2690,2692,2694],{"class":1350,"line":2645},23,[1348,2647,2560],{"class":1541},[1348,2649,2650],{"class":1354}," table ",[1348,2652,1548],{"class":1541},[1348,2654,2578],{"class":1354},[1348,2656,1554],{"class":1514},[1348,2658,2659],{"class":1510},"findall",[1348,2661,1579],{"class":1514},[1348,2663,2664],{"class":1506},"r",[1348,2666,2667],{"class":1514},"\"(?:",[1348,2669,1893],{"class":1585},[1348,2671,2672],{"class":1514},"|",[1348,2674,2675],{"class":1585},"JOIN",[1348,2677,2409],{"class":1514},[1348,2679,2680],{"class":1585},"\\s",[1348,2682,2683],{"class":1514},"+(",[1348,2685,2686],{"class":1585},"\\w",[1348,2688,2689],{"class":1514},"+)\"",[1348,2691,2092],{"class":1514},[1348,2693,2606],{"class":1510},[1348,2695,2483],{"class":1514},[1348,2697,2699,2701,2704,2706,2709,2711,2714,2717],{"class":1350,"line":2698},24,[1348,2700,2575],{"class":1541},[1348,2702,2703],{"class":1354}," table",[1348,2705,1554],{"class":1514},[1348,2707,2708],{"class":1510},"lower",[1348,2710,1515],{"class":1514},[1348,2712,2713],{"class":1514}," !=",[1348,2715,2716],{"class":1354}," ALLOWED_TABLE",[1348,2718,1525],{"class":1514},[1348,2720,2722,2724,2726,2728,2730,2733,2735,2737,2739,2742],{"class":1350,"line":2721},25,[1348,2723,2613],{"class":1541},[1348,2725,2491],{"class":1510},[1348,2727,1579],{"class":1514},[1348,2729,1582],{"class":1506},[1348,2731,2732],{"class":1585},"\"Table '",[1348,2734,1590],{"class":1589},[1348,2736,868],{"class":1510},[1348,2738,1601],{"class":1589},[1348,2740,2741],{"class":1585},"' is not allowed\"",[1348,2743,1626],{"class":1514},[1348,2745,2747],{"class":1350,"line":2746},26,[1348,2748,1395],{"emptyLinePlaceholder":1231},[1348,2750,2752],{"class":1350,"line":2751},27,[1348,2753,2754],{"class":1867},"        # Enforce LIMIT \u003C= 100\n",[1348,2756,2758,2760,2763,2765,2767,2769,2771],{"class":1350,"line":2757},28,[1348,2759,2156],{"class":1541},[1348,2761,2762],{"class":1510}," _enforce_limit",[1348,2764,1579],{"class":1514},[1348,2766,1860],{"class":1510},[1348,2768,2092],{"class":1514},[1348,2770,2606],{"class":1510},[1348,2772,1626],{"class":1514},[748,2774,2775],{},"Key constraints enforced:",[982,2777,2778,2794,2797,2804],{},[985,2779,2780,2781,2783,2784,2786,2787,2786,2789,2786,2791,2793],{},"Only ",[1345,2782,1873],{}," statements are allowed. Any ",[1345,2785,2235],{},", ",[1345,2788,2244],{},[1345,2790,2253],{},[1345,2792,2262],{},", or DDL keyword is blocked",[985,2795,2796],{},"Queries are restricted to a single analytics table (no access to user accounts, credentials, or other application data)",[985,2798,2799,2800,2803],{},"Result sets are capped at 100 rows, with ",[1345,2801,2802],{},"LIMIT"," enforced automatically",[985,2805,2806],{},"Comments, semicolons, and multi-statement queries are stripped or rejected",[748,2808,2809,2812],{},[805,2810,2811],{},"Read-only execution"," provides a second layer. The ClickHouse query runs over HTTP with safety parameters baked into every request:",[1339,2814,2817],{"className":1496,"code":2815,"filename":2816,"language":1499,"meta":1200,"style":1200},"response = httpx.post(\n    f\"http://{host}:{port}\",\n    params={\n        \"query\": f\"{sql} FORMAT JSON\",\n        \"database\": \"default\",\n        \"readonly\": \"1\",\n        \"max_execution_time\": \"10\",\n        \"max_result_rows\": \"100\",\n    },\n    timeout=15,\n)\n","executor.py",[1345,2818,2819,2837,2865,2873,2900,2920,2940,2960,2980,2985,2997],{"__ignoreMap":1200},[1348,2820,2821,2824,2826,2829,2831,2834],{"class":1350,"line":1351},[1348,2822,2823],{"class":1354},"response ",[1348,2825,1533],{"class":1514},[1348,2827,2828],{"class":1354}," httpx",[1348,2830,1554],{"class":1514},[1348,2832,2833],{"class":1510},"post",[1348,2835,2836],{"class":1514},"(\n",[1348,2838,2839,2842,2845,2847,2850,2852,2854,2856,2859,2861,2863],{"class":1350,"line":1201},[1348,2840,2841],{"class":1506},"    f",[1348,2843,2844],{"class":1585},"\"http://",[1348,2846,1590],{"class":1589},[1348,2848,2849],{"class":1510},"host",[1348,2851,1601],{"class":1589},[1348,2853,2404],{"class":1585},[1348,2855,1590],{"class":1589},[1348,2857,2858],{"class":1510},"port",[1348,2860,1601],{"class":1589},[1348,2862,1646],{"class":1585},[1348,2864,2276],{"class":1514},[1348,2866,2867,2870],{"class":1350,"line":1211},[1348,2868,2869],{"class":2401},"    params",[1348,2871,2872],{"class":1514},"={\n",[1348,2874,2875,2877,2880,2882,2884,2887,2889,2891,2893,2895,2898],{"class":1350,"line":1368},[1348,2876,1675],{"class":1514},[1348,2878,2879],{"class":1585},"query",[1348,2881,1646],{"class":1514},[1348,2883,2404],{"class":1514},[1348,2885,2886],{"class":1506}," f",[1348,2888,1646],{"class":1585},[1348,2890,1590],{"class":1589},[1348,2892,1860],{"class":1510},[1348,2894,1601],{"class":1589},[1348,2896,2897],{"class":1585}," FORMAT JSON\"",[1348,2899,2276],{"class":1514},[1348,2901,2902,2904,2907,2909,2911,2913,2916,2918],{"class":1350,"line":1374},[1348,2903,1675],{"class":1514},[1348,2905,2906],{"class":1585},"database",[1348,2908,1646],{"class":1514},[1348,2910,2404],{"class":1514},[1348,2912,1640],{"class":1514},[1348,2914,2915],{"class":1585},"default",[1348,2917,1646],{"class":1514},[1348,2919,2276],{"class":1514},[1348,2921,2922,2924,2927,2929,2931,2933,2936,2938],{"class":1350,"line":1380},[1348,2923,1675],{"class":1514},[1348,2925,2926],{"class":1585},"readonly",[1348,2928,1646],{"class":1514},[1348,2930,2404],{"class":1514},[1348,2932,1640],{"class":1514},[1348,2934,2935],{"class":1585},"1",[1348,2937,1646],{"class":1514},[1348,2939,2276],{"class":1514},[1348,2941,2942,2944,2947,2949,2951,2953,2956,2958],{"class":1350,"line":1386},[1348,2943,1675],{"class":1514},[1348,2945,2946],{"class":1585},"max_execution_time",[1348,2948,1646],{"class":1514},[1348,2950,2404],{"class":1514},[1348,2952,1640],{"class":1514},[1348,2954,2955],{"class":1585},"10",[1348,2957,1646],{"class":1514},[1348,2959,2276],{"class":1514},[1348,2961,2962,2964,2967,2969,2971,2973,2976,2978],{"class":1350,"line":1392},[1348,2963,1675],{"class":1514},[1348,2965,2966],{"class":1585},"max_result_rows",[1348,2968,1646],{"class":1514},[1348,2970,2404],{"class":1514},[1348,2972,1640],{"class":1514},[1348,2974,2975],{"class":1585},"100",[1348,2977,1646],{"class":1514},[1348,2979,2276],{"class":1514},[1348,2981,2982],{"class":1350,"line":1398},[1348,2983,2984],{"class":1514},"    },\n",[1348,2986,2987,2990,2992,2995],{"class":1350,"line":1404},[1348,2988,2989],{"class":2401},"    timeout",[1348,2991,1533],{"class":1514},[1348,2993,2994],{"class":1589},"15",[1348,2996,2276],{"class":1514},[1348,2998,2999],{"class":1350,"line":1410},[1348,3000,1626],{"class":1514},[748,3002,3003,3004,3007],{},"Even if SQL Guard missed something, ClickHouse itself would block writes (",[1345,3005,3006],{},"readonly=1","), kill slow queries (10 seconds), and cap the result set.",[748,3009,3010],{},"This belt-and-suspenders approach means that even if the LLM generates a malicious query (unlikely, but possible), it cannot modify data, access unauthorised tables, or run expensive long-running operations.",[855,3012,3013],{},[748,3014,3015],{},"No AI system should have write access to production data. Always enforce read-only execution at both the application layer and the database layer.",[758,3017,3019],{"id":3018},"why-clickhouse-for-music-analytics","Why ClickHouse for music analytics",[748,3021,3022],{},"We chose ClickHouse as the analytics engine for MusicData Lab because music streaming data is a textbook columnar analytics workload: append-only, time-series, high-volume, and query-heavy.",[748,3024,3025],{},"A typical label might have 10 to 50 million rows of streaming data, partitioned by month. Common queries aggregate by artist, retailer, territory, or time period. ClickHouse handles these in milliseconds where PostgreSQL would take seconds or minutes.",[748,3027,3028],{},"Key advantages for music data:",[982,3030,3031,3037,3054,3060],{},[985,3032,3033,3036],{},[805,3034,3035],{},"MergeTree engine"," with monthly partitioning matches the natural cadence of royalty reporting",[985,3038,3039,3042,3043,2786,3046,3049,3050,3053],{},[805,3040,3041],{},"Low-cardinality string optimisation"," is perfect for fields like ",[1345,3044,3045],{},"retailer_union",[1345,3047,3048],{},"artist",", and ",[1345,3051,3052],{},"country_code"," that have a bounded set of values",[985,3055,3056,3059],{},[805,3057,3058],{},"Columnar compression"," keeps storage costs low even as data grows to hundreds of millions of rows",[985,3061,3062,3065],{},[805,3063,3064],{},"HTTP API"," makes it straightforward to build read-only query interfaces with proper access controls",[758,3067,3069],{"id":3068},"what-we-learned-building-this","What we learned building this",[748,3071,3072],{},"Three insights from the implementation that apply to any company considering AI-powered analytics:",[1056,3074,3076],{"id":3075},"_1-the-prompt-is-the-product","1. The prompt is the product",[748,3078,3079],{},"The quality of SQL generation depends almost entirely on the system prompt. Including the full schema, domain-specific hints, and few-shot examples made the difference between \"sometimes works\" and \"reliably useful.\" As shown in the code above, we dynamically build the prompt from Django model metadata, so it stays in sync with schema changes automatically. No manual updates when a column is added or renamed.",[1056,3081,3083],{"id":3082},"_2-start-local-scale-to-cloud","2. Start local, scale to cloud",[748,3085,3086],{},"Running Ollama locally for development and testing removed the biggest adoption barrier: \"we can't send data to an external API.\" Once stakeholders see the value, the conversation about using a cloud API for better quality becomes much easier.",[1056,3088,3090],{"id":3089},"_3-security-is-a-feature-not-a-constraint","3. Security is a feature, not a constraint",[748,3092,3093],{},"The SQL Guard and read-only execution are not just safety nets. They are what made the business comfortable deploying this. When your CFO asks \"can this AI delete our data?\", the answer needs to be a confident \"no, here's why.\"",[758,3095,3097],{"id":3096},"who-is-this-for","Who is this for?",[748,3099,3100],{},"This is not a product launch. It is a proof of concept that we built for our own platform and for our clients. If you recognise any of these situations, it might be relevant to you:",[982,3102,3103,3106,3109,3112],{},[985,3104,3105],{},"Your data team spends too much time answering ad-hoc reporting requests",[985,3107,3108],{},"Business stakeholders wait days for insights that should take seconds",[985,3110,3111],{},"You have sensitive royalty or financial data and cannot use cloud-based AI tools",[985,3113,3114],{},"You already have analytics data in ClickHouse, PostgreSQL, or a similar database and want to make it more accessible",[748,3116,3117],{},"At MusicTech Lab, we build data platforms for the music industry. The AI Dashboard is one piece of a larger system that handles royalty ingestion, normalisation, currency conversion, and reporting. If this resonates, we should talk.",[758,3119,3121],{"id":3120},"what-comes-next","What comes next",[748,3123,3124],{},"This is a v1. The underlying pattern, natural language to SQL to visualisation, is not limited to music data. Any company with a structured analytics database can benefit from making that data conversational. The technology is ready. The question is whether your organisation is ready to let business users ask their own questions.",[3126,3127,3128],"style",{},"html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .s2Zo4, html code.shiki .s2Zo4{--shiki-light:#6182B8;--shiki-default:#82AAFF;--shiki-dark:#82AAFF}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sBMFI, html code.shiki .sBMFI{--shiki-light:#E2931D;--shiki-default:#FFCB6B;--shiki-dark:#FFCB6B}html pre.shiki code .s7zQu, html code.shiki .s7zQu{--shiki-light:#39ADB5;--shiki-light-font-style:italic;--shiki-default:#89DDFF;--shiki-default-font-style:italic;--shiki-dark:#89DDFF;--shiki-dark-font-style:italic}html pre.shiki code .swJcz, html code.shiki .swJcz{--shiki-light:#E53935;--shiki-default:#F07178;--shiki-dark:#F07178}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html pre.shiki code .sbssI, html code.shiki .sbssI{--shiki-light:#F76D47;--shiki-default:#F78C6C;--shiki-dark:#F78C6C}html pre.shiki code .sHdIc, html code.shiki .sHdIc{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#EEFFFF;--shiki-default-font-style:italic;--shiki-dark:#BABED8;--shiki-dark-font-style:italic}html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}",{"title":1200,"searchDepth":1201,"depth":1201,"links":3130},[3131,3132,3133,3134,3135,3136,3137,3142,3143],{"id":1277,"depth":1201,"text":1278},{"id":1313,"depth":1201,"text":1314},{"id":1477,"depth":1201,"text":1478},{"id":2023,"depth":1201,"text":2024},{"id":2201,"depth":1201,"text":2202},{"id":3018,"depth":1201,"text":3019},{"id":3068,"depth":1201,"text":3069,"children":3138},[3139,3140,3141],{"id":3075,"depth":1211,"text":3076},{"id":3082,"depth":1211,"text":3083},{"id":3089,"depth":1211,"text":3090},{"id":3096,"depth":1201,"text":3097},{"id":3120,"depth":1201,"text":3121},"2026-03-24T00:00:00.000Z","Music royalty data is complex. Non-technical stakeholders need insights without SQL. Here's how we built an AI dashboard that turns plain English into charts.",[3147,3150,3153,3156],{"question":3148,"answer":3149},"Can the AI dashboard work without sending data to the cloud?","Yes. The default setup uses Ollama, an open-source local LLM runtime. All queries, data, and model inference stay on your own infrastructure. No streaming data, royalty figures, or artist names ever leave your servers.",{"question":3151,"answer":3152},"What kind of questions can non-technical users ask?","Users can ask plain English questions like 'top 5 artists by income', 'monthly revenue trend for 2024', or 'income by country'. The AI generates a ClickHouse SQL query, executes it safely, and returns a chart with the results.",{"question":3154,"answer":3155},"How does the system prevent dangerous SQL queries?","A dedicated SQL security layer validates every generated query before execution. It enforces SELECT-only access, restricts queries to a single analytics table, caps result sizes, and runs against a read-only ClickHouse connection with query timeouts.",{"question":3157,"answer":3158},"What technology stack powers the AI dashboard?","Django for the web framework, ClickHouse for columnar analytics, Ollama (or Claude API) for LLM inference, and Chart.js for visualization. The architecture is pluggable, so the LLM provider can be swapped without changing application code.",{"src":3160},"/images/blog/musictechlab_blog_ai-powered-analytics-dashboard.webp",{"enabled":1231,"items":3162},[3163,3165,3168,3171],{"text":3164,"icon":1473},"Business users type questions in plain English and get charts back in seconds, no SQL needed.",{"text":3166,"icon":3167},"Ollama runs locally by default so no royalty data ever leaves your servers.","i-lucide-shield",{"text":3169,"icon":3170},"SQL Guard enforces SELECT-only, single-table access with a 100-row limit on every query.","i-lucide-lock",{"text":3172,"icon":3173},"ClickHouse handles 10-50M rows with sub-second query times for columnar analytics.","i-lucide-zap",{},{"title":3176,"description":3177},"AI Analytics Dashboard for Music Data | MusicTech Lab","How we built a generative AI dashboard that turns natural language into ClickHouse SQL and charts - with local LLM, zero data exposure.",[3179,1252,3180,3181,1216,3182],"ai-analytics","clickhouse","llm","data-visualization","pS6Y0h5-QTtmmgq3FGyIwCVS8U7L5KQD1FBN31L-WS8",{"id":3185,"title":80,"authors":3186,"badge":3189,"body":3190,"category":1216,"client":1217,"date":3831,"description":3832,"extension":1220,"faq":3833,"featured":1231,"featuredOrder":1201,"hidden":69,"image":3843,"keyTakeaways":3845,"meta":3857,"navigation":1231,"path":81,"seo":3858,"status":1217,"stem":82,"tags":3861,"teaser":1217,"__hash__":3864,"score":1211},"posts/blog/music-data/830-ways-to-say-spotify-normalizing-music-streaming-data.md",[3187],{"name":738,"to":739,"avatar":3188},{"src":741},{"label":5,"color":743},{"type":745,"value":3191,"toc":3820},[3192,3199,3202,3206,3209,3299,3306,3310,3317,3424,3427,3433,3444,3459,3462,3466,3469,3472,3477,3483,3487,3490,3494,3497,3581,3584,3588,3591,3657,3660,3664,3667,3797,3800,3804,3807,3814,3817],[748,3193,3194,3195,3198],{},"In our ",[1483,3196,3197],{"href":77},"previous article",", we showed what a real label's download folder looks like - 18 distributors, 5 file formats, 500+ files per year. But solving the file format problem is only step one.",[748,3200,3201],{},"Open those files. The data inside is just as messy.",[758,3203,3205],{"id":3204},"the-name-problem","The name problem",[748,3207,3208],{},"Ask 18 distributors what \"Spotify\" is called. You'll get more answers than you expect:",[868,3210,3211,3221],{},[871,3212,3213],{},[874,3214,3215,3218],{},[877,3216,3217],{},"Distributor",[877,3219,3220],{},"How they label Spotify",[887,3222,3223,3233,3243,3253,3263,3271,3280,3289],{},[874,3224,3225,3228],{},[892,3226,3227],{},"FUGA",[892,3229,3230],{},[1345,3231,3232],{},"Spotify",[874,3234,3235,3238],{},[892,3236,3237],{},"ADA",[892,3239,3240],{},[1345,3241,3242],{},"SPOTIFY",[874,3244,3245,3248],{},[892,3246,3247],{},"Ingrooves",[892,3249,3250],{},[1345,3251,3252],{},"spotify",[874,3254,3255,3258],{},[892,3256,3257],{},"The Orchard",[892,3259,3260],{},[1345,3261,3262],{},"Spotify Premium",[874,3264,3265,3268],{},[892,3266,3267],{},"Bandcamp",[892,3269,3270],{},"N/A",[874,3272,3273,3276],{},[892,3274,3275],{},"MVD",[892,3277,3278],{},[1345,3279,3232],{},[874,3281,3282,3285],{},[892,3283,3284],{},"Emerald",[892,3286,3287],{},[1345,3288,3242],{},[874,3290,3291,3294],{},[892,3292,3293],{},"SFM",[892,3295,3296],{},[1345,3297,3298],{},"Spotify AB",[748,3300,3301,3302,3305],{},"That's just one platform. Now multiply across ",[805,3303,3304],{},"every retailer, every label name, and every service type"," in the dataset. The same entity appears under different names, different capitalizations, and different abbreviations depending on which distributor sent the file.",[758,3307,3309],{"id":3308},"_830-values-19-names","830 values, 19 names",[748,3311,3312,3313,3316],{},"The solution is what we call ",[805,3314,3315],{},"unions"," - normalization groups that map many raw values to one canonical name.",[788,3318,3322],{"className":3319},[3320,3321,795],"flex","justify-center",[1339,3323,3325],{"className":1341,"code":3324,"language":1343,"meta":1200,"style":1200},"flowchart LR\n    subgraph raw[\"Raw Values\"]\n        A[\"Spotify\"]\n        B[\"SPOTIFY\"]\n        C[\"spotify\"]\n        D[\"Spotify Premium\"]\n        E[\"Spotify AB\"]\n        F[\"Spotify Ltd\"]\n    end\n\n    subgraph union[\"Union\"]\n        G([\"SPOTIFY\"])\n    end\n\n    A --> G\n    B --> G\n    C --> G\n    D --> G\n    E --> G\n    F --> G\n",[1345,3326,3327,3332,3337,3342,3347,3352,3357,3362,3367,3372,3376,3381,3386,3390,3394,3399,3404,3409,3414,3419],{"__ignoreMap":1200},[1348,3328,3329],{"class":1350,"line":1351},[1348,3330,3331],{"class":1354},"flowchart LR\n",[1348,3333,3334],{"class":1350,"line":1201},[1348,3335,3336],{"class":1354},"    subgraph raw[\"Raw Values\"]\n",[1348,3338,3339],{"class":1350,"line":1211},[1348,3340,3341],{"class":1354},"        A[\"Spotify\"]\n",[1348,3343,3344],{"class":1350,"line":1368},[1348,3345,3346],{"class":1354},"        B[\"SPOTIFY\"]\n",[1348,3348,3349],{"class":1350,"line":1374},[1348,3350,3351],{"class":1354},"        C[\"spotify\"]\n",[1348,3353,3354],{"class":1350,"line":1380},[1348,3355,3356],{"class":1354},"        D[\"Spotify Premium\"]\n",[1348,3358,3359],{"class":1350,"line":1386},[1348,3360,3361],{"class":1354},"        E[\"Spotify AB\"]\n",[1348,3363,3364],{"class":1350,"line":1392},[1348,3365,3366],{"class":1354},"        F[\"Spotify Ltd\"]\n",[1348,3368,3369],{"class":1350,"line":1398},[1348,3370,3371],{"class":1354},"    end\n",[1348,3373,3374],{"class":1350,"line":1404},[1348,3375,1395],{"emptyLinePlaceholder":1231},[1348,3377,3378],{"class":1350,"line":1410},[1348,3379,3380],{"class":1354},"    subgraph union[\"Union\"]\n",[1348,3382,3383],{"class":1350,"line":1416},[1348,3384,3385],{"class":1354},"        G([\"SPOTIFY\"])\n",[1348,3387,3388],{"class":1350,"line":1422},[1348,3389,3371],{"class":1354},[1348,3391,3392],{"class":1350,"line":1428},[1348,3393,1395],{"emptyLinePlaceholder":1231},[1348,3395,3396],{"class":1350,"line":1434},[1348,3397,3398],{"class":1354},"    A --> G\n",[1348,3400,3401],{"class":1350,"line":1440},[1348,3402,3403],{"class":1354},"    B --> G\n",[1348,3405,3406],{"class":1350,"line":1446},[1348,3407,3408],{"class":1354},"    C --> G\n",[1348,3410,3411],{"class":1350,"line":1452},[1348,3412,3413],{"class":1354},"    D --> G\n",[1348,3415,3416],{"class":1350,"line":1814},[1348,3417,3418],{"class":1354},"    E --> G\n",[1348,3420,3421],{"class":1350,"line":1826},[1348,3422,3423],{"class":1354},"    F --> G\n",[748,3425,3426],{},"Here's what the union management interface looks like in practice - TIDAL alone has 10 variations, SoundCloud over 30:",[748,3428,3429],{},[957,3430],{"alt":3431,"src":3432},"Union management interface showing raw value mappings","/images/blog/musictechlab_blog_unions-admin-interface.webp",[748,3434,3435,3436,3439,3440,3443],{},"In production, this system maps ",[805,3437,3438],{},"830 raw tag values"," to just ",[805,3441,3442],{},"19 unions"," across three dimensions:",[788,3445,3447,3451,3455],{"className":3446},[791,792,1111,1459,795],[797,3448],{"description":3449,"title":3450},"Spotify, Amazon, YouTube, Pandora, Facebook, and more. Each with dozens of naming variations across sources.","Retailer unions",[797,3452],{"description":3453,"title":3454},"The label itself can appear under different names, abbreviations, or legal entity variations across distributors.","Label unions",[797,3456],{"description":3457,"title":3458},"Streaming, downloads, physical, sync - each distributor categorizes their services differently.","Service unions",[748,3460,3461],{},"When a file is imported, every raw value is checked against the union mappings. If it matches, the canonical name is stored alongside the original. If it doesn't, the raw value is preserved - and flagged for review.",[758,3463,3465],{"id":3464},"why-not-just-find-and-replace","Why not just find-and-replace?",[748,3467,3468],{},"Because new values appear constantly. A distributor adds a sub-brand. Another changes their internal naming. A third introduces a typo that persists for six months before anyone notices. Static find-and-replace breaks every time the data evolves.",[748,3470,3471],{},"Unions are dynamic. A non-technical user can add a new mapping through an admin interface - no code changes, no redeployment. The next import picks it up automatically.",[772,3473,3474],{},[748,3475,3476],{},"One real example: a distributor renamed their Spotify column from \"Spotify\" to \"Spotify AB\" mid-year. Without union mapping, this would have created a second \"Spotify\" in every report, splitting the data and making quarterly comparisons unreliable.",[748,3478,3479],{},[957,3480],{"alt":3481,"src":3482},"Currency exchange and data normalization","/images/blog/musictechlab_blog_currency-exchange-data.webp",[758,3484,3486],{"id":3485},"the-cherry-on-top-currencies-and-territories","The cherry on top: currencies and territories",[748,3488,3489],{},"Even after normalizing names, two more problems remain.",[1056,3491,3493],{"id":3492},"currencies","Currencies",[748,3495,3496],{},"Every distributor reports income in their own currency. And they don't even agree on what to call the currency column:",[868,3498,3499,3511],{},[871,3500,3501],{},[874,3502,3503,3505,3508],{},[877,3504,3217],{},[877,3506,3507],{},"Column name",[877,3509,3510],{},"Default currency",[887,3512,3513,3525,3535,3546,3558,3570],{},[874,3514,3515,3517,3522],{},[892,3516,3227],{},[892,3518,3519],{},[1345,3520,3521],{},"Original currency",[892,3523,3524],{},"varies",[874,3526,3527,3529,3532],{},[892,3528,3237],{},[892,3530,3531],{},"(file-level)",[892,3533,3534],{},"GBP",[874,3536,3537,3539,3544],{},[892,3538,3247],{},[892,3540,3541],{},[1345,3542,3543],{},"CURRENCY_CODE",[892,3545,3534],{},[874,3547,3548,3550,3555],{},[892,3549,3257],{},[892,3551,3552],{},[1345,3553,3554],{},"Preferred Currency",[892,3556,3557],{},"USD",[874,3559,3560,3563,3568],{},[892,3561,3562],{},"Merlin",[892,3564,3565],{},[1345,3566,3567],{},"Payable currency",[892,3569,3524],{},[874,3571,3572,3574,3579],{},[892,3573,3275],{},[892,3575,3576],{},[1345,3577,3578],{},"currency-code",[892,3580,3524],{},[748,3582,3583],{},"To compare income across sources, every value needs to be converted to a single target currency using exchange rates tied to specific dates. A $1,000 Orchard payment and a £800 ADA payment aren't comparable until you normalize them.",[1056,3585,3587],{"id":3586},"territories","Territories",[748,3589,3590],{},"Countries seem straightforward - until you see how distributors label them:",[868,3592,3593,3603],{},[871,3594,3595],{},[874,3596,3597,3600],{},[877,3598,3599],{},"The same country",[877,3601,3602],{},"Variations across sources",[887,3604,3605,3623,3641],{},[874,3606,3607,3610],{},[892,3608,3609],{},"United States",[892,3611,3612,2786,3615,2786,3618,2786,3621],{},[1345,3613,3614],{},"US",[1345,3616,3617],{},"USA",[1345,3619,3620],{},"UNITED STATES",[1345,3622,3609],{},[874,3624,3625,3628],{},[892,3626,3627],{},"United Kingdom",[892,3629,3630,2786,3633,2786,3636,2786,3639],{},[1345,3631,3632],{},"UK",[1345,3634,3635],{},"GB",[1345,3637,3638],{},"UNITED KINGDOM (GB)",[1345,3640,3627],{},[874,3642,3643,3646],{},[892,3644,3645],{},"Czech Republic",[892,3647,3648,2786,3651,2786,3654],{},[1345,3649,3650],{},"CZ",[1345,3652,3653],{},"CZECH REPUBLIC",[1345,3655,3656],{},"Czechia",[748,3658,3659],{},"Each variation needs to map to a standard two-letter code. Without this, \"show me all US streams\" misses half the data.",[758,3661,3663],{"id":3662},"three-layers-of-normalization","Three layers of normalization",[748,3665,3666],{},"Here's the full picture - what it actually takes to turn raw distributor data into something usable:",[788,3668,3670],{"className":3669},[3320,3321,795],[1339,3671,3673],{"className":1341,"code":3672,"language":1343,"meta":1200,"style":1200},"flowchart TD\n    subgraph layer1[\"Layer 1: File Formats\"]\n        A[\"18 adapters\"]\n        B[\"5 formats\"]\n        C[\"500+ files/year\"]\n    end\n\n    subgraph layer2[\"Layer 2: Name Normalization\"]\n        D[\"830 raw values\"]\n        E[\"19 unions\"]\n        F[\"3 dimensions\"]\n    end\n\n    subgraph layer3[\"Layer 3: Currency & Territory\"]\n        G[\"Multiple currencies\"]\n        H[\"Exchange rates\"]\n        I[\"Country code mapping\"]\n    end\n\n    subgraph result[\"Result\"]\n        J[(\"One clean dataset\")]\n    end\n\n    layer1 --> layer2\n    layer2 --> layer3\n    layer3 --> result\n",[1345,3674,3675,3680,3685,3690,3695,3700,3704,3708,3713,3718,3723,3728,3732,3736,3741,3746,3751,3756,3760,3764,3769,3774,3778,3782,3787,3792],{"__ignoreMap":1200},[1348,3676,3677],{"class":1350,"line":1351},[1348,3678,3679],{"class":1354},"flowchart TD\n",[1348,3681,3682],{"class":1350,"line":1201},[1348,3683,3684],{"class":1354},"    subgraph layer1[\"Layer 1: File Formats\"]\n",[1348,3686,3687],{"class":1350,"line":1211},[1348,3688,3689],{"class":1354},"        A[\"18 adapters\"]\n",[1348,3691,3692],{"class":1350,"line":1368},[1348,3693,3694],{"class":1354},"        B[\"5 formats\"]\n",[1348,3696,3697],{"class":1350,"line":1374},[1348,3698,3699],{"class":1354},"        C[\"500+ files/year\"]\n",[1348,3701,3702],{"class":1350,"line":1380},[1348,3703,3371],{"class":1354},[1348,3705,3706],{"class":1350,"line":1386},[1348,3707,1395],{"emptyLinePlaceholder":1231},[1348,3709,3710],{"class":1350,"line":1392},[1348,3711,3712],{"class":1354},"    subgraph layer2[\"Layer 2: Name Normalization\"]\n",[1348,3714,3715],{"class":1350,"line":1398},[1348,3716,3717],{"class":1354},"        D[\"830 raw values\"]\n",[1348,3719,3720],{"class":1350,"line":1404},[1348,3721,3722],{"class":1354},"        E[\"19 unions\"]\n",[1348,3724,3725],{"class":1350,"line":1410},[1348,3726,3727],{"class":1354},"        F[\"3 dimensions\"]\n",[1348,3729,3730],{"class":1350,"line":1416},[1348,3731,3371],{"class":1354},[1348,3733,3734],{"class":1350,"line":1422},[1348,3735,1395],{"emptyLinePlaceholder":1231},[1348,3737,3738],{"class":1350,"line":1428},[1348,3739,3740],{"class":1354},"    subgraph layer3[\"Layer 3: Currency & Territory\"]\n",[1348,3742,3743],{"class":1350,"line":1434},[1348,3744,3745],{"class":1354},"        G[\"Multiple currencies\"]\n",[1348,3747,3748],{"class":1350,"line":1440},[1348,3749,3750],{"class":1354},"        H[\"Exchange rates\"]\n",[1348,3752,3753],{"class":1350,"line":1446},[1348,3754,3755],{"class":1354},"        I[\"Country code mapping\"]\n",[1348,3757,3758],{"class":1350,"line":1452},[1348,3759,3371],{"class":1354},[1348,3761,3762],{"class":1350,"line":1814},[1348,3763,1395],{"emptyLinePlaceholder":1231},[1348,3765,3766],{"class":1350,"line":1826},[1348,3767,3768],{"class":1354},"    subgraph result[\"Result\"]\n",[1348,3770,3771],{"class":1350,"line":1849},[1348,3772,3773],{"class":1354},"        J[(\"One clean dataset\")]\n",[1348,3775,3776],{"class":1350,"line":2639},[1348,3777,3371],{"class":1354},[1348,3779,3780],{"class":1350,"line":2645},[1348,3781,1395],{"emptyLinePlaceholder":1231},[1348,3783,3784],{"class":1350,"line":2698},[1348,3785,3786],{"class":1354},"    layer1 --> layer2\n",[1348,3788,3789],{"class":1350,"line":2721},[1348,3790,3791],{"class":1354},"    layer2 --> layer3\n",[1348,3793,3794],{"class":1350,"line":2746},[1348,3795,3796],{"class":1354},"    layer3 --> result\n",[748,3798,3799],{},"Most teams get stuck at Layer 1 and never even reach the name and currency problems. But without all three layers, you can't answer basic questions like \"What were our total Spotify streams in Q3, in GBP?\"",[758,3801,3803],{"id":3802},"the-payoff","The payoff",[748,3805,3806],{},"After all three normalization layers, that question becomes trivial. Filter by retailer union, filter by date range, and every value is already in GBP. One query. One answer. No spreadsheet wrangling.",[748,3808,3809,3810,3813],{},"And once the data is clean, even non-technical users can get answers. We built an ",[1483,3811,3812],{"href":85},"AI-powered analytics dashboard"," that lets anyone type a question in plain English and get a chart back in seconds, no SQL required.",[748,3815,3816],{},"That's the difference between a collection of files and a data platform. We build the latter.",[3126,3818,3819],{},"html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":1200,"searchDepth":1201,"depth":1201,"links":3821},[3822,3823,3824,3825,3829,3830],{"id":3204,"depth":1201,"text":3205},{"id":3308,"depth":1201,"text":3309},{"id":3464,"depth":1201,"text":3465},{"id":3485,"depth":1201,"text":3486,"children":3826},[3827,3828],{"id":3492,"depth":1211,"text":3493},{"id":3586,"depth":1211,"text":3587},{"id":3662,"depth":1201,"text":3663},{"id":3802,"depth":1201,"text":3803},"2026-03-03T00:00:00.000Z","After solving the file format problem, the data inside is just as messy. Different names for the same platforms, labels, currencies, and territories. Here's how we normalize it.",[3834,3837,3840],{"question":3835,"answer":3836},"Why is music streaming data so inconsistent?","Every distributor uses their own naming conventions for retailers, labels, services, territories, and currencies. There is no shared standard, so the same platform can appear under dozens of different names across data sources.",{"question":3838,"answer":3839},"What is a data union in the context of music royalties?","A union is a normalization group that maps multiple raw values to one canonical name. For example, all variations of 'Spotify' across 18 distributors get mapped to a single SPOTIFY union, making cross-source analysis possible.",{"question":3841,"answer":3842},"How do currency differences affect music royalty reporting?","Distributors report income in different currencies (USD, GBP, EUR) and even label the currency column differently. To compare or aggregate income across sources, every value must be converted to a single target currency using exchange rates.",{"src":3844},"/images/blog/musictechlab_blog_830-ways-to-say-spotify.webp",{"enabled":1231,"items":3846},[3847,3849,3852,3855],{"text":3848,"icon":1468},"830 raw data values map to just 19 canonical names across retailers, labels, and services.",{"text":3850,"icon":3851},"Three normalization layers are needed: file formats, name unions, and currency/territory mapping.","i-lucide-layers",{"text":3853,"icon":3854},"Unions are dynamic and admin-editable, so new naming variations need no code changes.","i-lucide-settings",{"text":3856,"icon":1238},"Without all three layers, even basic queries like total Spotify streams in Q3 are impossible.",{},{"title":3859,"description":3860},"Normalizing Music Streaming Data: 830 Values, 19 Unions | MusicTech Lab","How we normalize 830 raw data values into 19 canonical names across retailers, labels, and services. Real-world music data normalization.",[1216,1252,3862,3863],"streaming","metadata","nkhKea-amz1NLgquVd-h3Qvo8Tq2jJzXtEx0Fmt3TeE",{"id":3866,"title":76,"authors":3867,"badge":3870,"body":3873,"category":1216,"client":1217,"date":4540,"description":4541,"extension":1220,"faq":4542,"featured":1231,"featuredOrder":1351,"hidden":69,"image":4552,"keyTakeaways":4554,"meta":4565,"navigation":1231,"path":77,"seo":4566,"status":1217,"stem":78,"tags":4569,"teaser":1217,"__hash__":4571,"score":1211},"posts/blog/music-data/13-distributors-5-file-formats-zero-standards-the-reality-of-music-royalty-data.md",[3868],{"name":738,"to":739,"avatar":3869},{"src":741},{"label":3871,"color":3872},"Distribution","#0ea5e9",{"type":745,"value":3874,"toc":4532},[3875,3878,3881,3885,3888,4169,4176,4180,4183,4202,4205,4209,4212,4215,4226,4231,4237,4241,4244,4344,4347,4351,4354,4502,4505,4516,4523,4527,4530],[748,3876,3877],{},"Every month, an independent label receives royalty reports from over a dozen distributors. Not a single one looks the same.",[748,3879,3880],{},"This isn't a hypothetical. This is what a real download folder looks like when you work with music royalty data at scale.",[758,3882,3884],{"id":3883},"the-wall-of-files","The wall of files",[748,3886,3887],{},"Here's a small sample of actual filenames from a single label's monthly data intake -anonymised, but otherwise untouched:",[868,3889,3890,3905],{},[871,3891,3892],{},[874,3893,3894,3896,3899,3902],{},[877,3895,3217],{},[877,3897,3898],{},"Example Filename",[877,3900,3901],{},"Format",[877,3903,3904],{},"File Size",[887,3906,3907,3922,3937,3952,3967,3981,3995,4008,4022,4037,4053,4068,4083,4098,4112,4126,4140,4155],{},[874,3908,3909,3911,3916,3919],{},[892,3910,3227],{},[892,3912,3913],{},[1345,3914,3915],{},"FUGA_Statement_June_2024.xlsx",[892,3917,3918],{},".xlsx",[892,3920,3921],{},"~1 MB",[874,3923,3924,3926,3931,3934],{},[892,3925,3237],{},[892,3927,3928],{},[1345,3929,3930],{},"SR1_Distribution_Aug_24_-_00054061_-_2024-8.xlsb",[892,3932,3933],{},".xlsb",[892,3935,3936],{},"~70 KB",[874,3938,3939,3941,3946,3949],{},[892,3940,3247],{},[892,3942,3943],{},[1345,3944,3945],{},"20240801-1496-DS-GBP_Digital_Sales.csv",[892,3947,3948],{},".csv",[892,3950,3951],{},"up to 226 MB",[874,3953,3954,3956,3961,3964],{},[892,3955,3257],{},[892,3957,3958],{},[1345,3959,3960],{},"The_Orchard20240821_Jun2024_fullreport_catalogue_US.xls",[892,3962,3963],{},".xls",[892,3965,3966],{},"up to 700 MB",[874,3968,3969,3971,3976,3978],{},[892,3970,3267],{},[892,3972,3973],{},[1345,3974,3975],{},"bandcamp_rev_report_20240801-20240831.csv",[892,3977,3948],{},[892,3979,3980],{},"~6 KB",[874,3982,3983,3985,3990,3992],{},[892,3984,3275],{},[892,3986,3987],{},[1345,3988,3989],{},"MVD_Statement_DigitalSales_2024-07.xls",[892,3991,3963],{},[892,3993,3994],{},"~12 KB",[874,3996,3997,3999,4004,4006],{},[892,3998,3275],{},[892,4000,4001],{},[1345,4002,4003],{},"MVD_Statement_DigitalSales_2024-07.xlsx",[892,4005,3918],{},[892,4007,3994],{},[874,4009,4010,4012,4017,4019],{},[892,4011,3284],{},[892,4013,4014],{},[1345,4015,4016],{},"Emerald_202408_DSR.csv",[892,4018,3948],{},[892,4020,4021],{},"~46 MB",[874,4023,4024,4027,4032,4034],{},[892,4025,4026],{},"Safari Records",[892,4028,4029],{},[1345,4030,4031],{},"Safari_Records_202408_DSR.xlsx",[892,4033,3918],{},[892,4035,4036],{},"~2 MB",[874,4038,4039,4042,4047,4050],{},[892,4040,4041],{},"ADA (legacy)",[892,4043,4044],{},[1345,4045,4046],{},"ADAOCT1.XLS",[892,4048,4049],{},".XLS",[892,4051,4052],{},"up to 150 MB",[874,4054,4055,4058,4063,4065],{},[892,4056,4057],{},"MAC",[892,4059,4060],{},[1345,4061,4062],{},"MAC_Developments_iTunes_August_2024.xlsx",[892,4064,3918],{},[892,4066,4067],{},"~49 KB",[874,4069,4070,4073,4078,4081],{},[892,4071,4072],{},"Absolute",[892,4074,4075],{},[1345,4076,4077],{},"Absolute_2024021.CSV",[892,4079,4080],{},".CSV",[892,4082,3951],{},[874,4084,4085,4088,4093,4095],{},[892,4086,4087],{},"Qello",[892,4089,4090],{},[1345,4091,4092],{},"DetailedSheet_Records_Ltd_20240801_20240831.xlsx",[892,4094,3918],{},[892,4096,4097],{},"~10 KB",[874,4099,4100,4102,4107,4109],{},[892,4101,3293],{},[892,4103,4104],{},[1345,4105,4106],{},"sfmaug2024.xlsx",[892,4108,3918],{},[892,4110,4111],{},"~2.5 MB",[874,4113,4114,4117,4122,4124],{},[892,4115,4116],{},"BOFM",[892,4118,4119],{},[1345,4120,4121],{},"BOFM_Aug2024.xlsx",[892,4123,3918],{},[892,4125,4111],{},[874,4127,4128,4131,4136,4138],{},[892,4129,4130],{},"Dome Records",[892,4132,4133],{},[1345,4134,4135],{},"Dome_Records_202408_DSR.csv",[892,4137,3948],{},[892,4139,3921],{},[874,4141,4142,4145,4150,4152],{},[892,4143,4144],{},"MDR",[892,4146,4147],{},[1345,4148,4149],{},"MDR_May-2024_65634.92_Records.xlsx",[892,4151,3918],{},[892,4153,4154],{},"~500 KB",[874,4156,4157,4159,4164,4166],{},[892,4158,3562],{},[892,4160,4161],{},[1345,4162,4163],{},"Merlin_Nov24_eg.for.jack.xlsx",[892,4165,3918],{},[892,4167,4168],{},"~703 KB",[748,4170,4171,4172,4175],{},"That's ",[805,4173,4174],{},"18 adapters across 500+ files per year"," -each with its own naming convention, file format, and internal structure. From a 6 KB Bandcamp CSV to a single Orchard report that can reach 700 MB.",[758,4177,4179],{"id":4178},"spot-the-pattern","Spot the pattern",[748,4181,4182],{},"Go ahead, try. You won't find one.",[788,4184,4186,4190,4194,4198],{"className":4185},[791,792,793,1459,795],[797,4187],{"description":4188,"title":4189},"`.xlsx` · `.xlsb` · `.xls` · `.XLS` · `.csv` · `.CSV`","5 file formats",[797,4191],{"description":4192,"title":4193},"`2024-07` · `202408` · `Aug_24` · `August_2024` · `20240801-20240831` · `2024021`","6 date conventions in filenames",[797,4195],{"description":4196,"title":4197},"Some distributors send both `.xls` and `.xlsx` versions of the exact same data.","Same report, multiple formats",[797,4199],{"description":4200,"title":4201},"camelCase, ALLCAPS, underscores, hyphens, internal reference numbers, random hash suffixes.","No naming standard",[748,4203,4204],{},"And that's just the filenames. Open these files and you'll find different column names for the same data, different date formats inside the cells, different encodings, and multi-sheet workbooks where each sheet follows its own rules.",[758,4206,4208],{"id":4207},"why-this-matters","Why this matters",[748,4210,4211],{},"Someone has to make sense of all this. Every month.",[748,4213,4214],{},"For most independent labels, that means hours of manual work -copying data between spreadsheets, reformatting dates, matching column names, fixing encoding issues that turn artist names into garbled text.",[748,4216,4217,4218,4221,4222,4225],{},"The cost isn't just time. It's ",[805,4219,4220],{},"delayed royalty payments"," to artists. It's ",[805,4223,4224],{},"reporting errors"," that erode trust. It's the finance team spending their week on data cleanup instead of analysis.",[772,4227,4228],{},[748,4229,4230],{},"One distributor changed their report format mid-year without notice. The same filename pattern, but completely different column structure inside. Manual processes break silently when this happens.",[748,4232,4233],{},[957,4234],{"alt":4235,"src":4236},"Someone working on a laptop with spreadsheet data","/images/blog/musictechlab_blog_royalty-data-spreadsheet-laptop.webp",[758,4238,4240],{"id":4239},"how-teams-try-to-solve-this","How teams try to solve this",[748,4242,4243],{},"There's more than one way to tackle this problem. Here's how the most common approaches compare:",[868,4245,4246,4265],{},[871,4247,4248],{},[874,4249,4250,4253,4256,4259,4262],{},[877,4251,4252],{},"Approach",[877,4254,4255],{},"Setup effort",[877,4257,4258],{},"Maintenance",[877,4260,4261],{},"Handles format changes",[877,4263,4264],{},"Scales with new sources",[887,4266,4267,4286,4306,4325],{},[874,4268,4269,4274,4277,4280,4283],{},[892,4270,4271],{},[805,4272,4273],{},"Manual spreadsheets",[892,4275,4276],{},"None",[892,4278,4279],{},"Hours every month",[892,4281,4282],{},"Breaks silently",[892,4284,4285],{},"Every new source = more hours",[874,4287,4288,4294,4297,4300,4303],{},[892,4289,4290,4293],{},[805,4291,4292],{},"Generic ETL tools"," (Fivetran, Airbyte)",[892,4295,4296],{},"Medium",[892,4298,4299],{},"Low",[892,4301,4302],{},"Limited - connectors are generic",[892,4304,4305],{},"Only if a connector exists",[874,4307,4308,4313,4316,4319,4322],{},[892,4309,4310],{},[805,4311,4312],{},"Custom Python scripts",[892,4314,4315],{},"High",[892,4317,4318],{},"High - fragile, hard to maintain",[892,4320,4321],{},"Depends on the developer",[892,4323,4324],{},"Every new source = new script",[874,4326,4327,4332,4335,4338,4341],{},[892,4328,4329],{},[805,4330,4331],{},"Adapter-based pipeline",[892,4333,4334],{},"High upfront",[892,4336,4337],{},"Low - each adapter is isolated",[892,4339,4340],{},"Adapter update, no side effects",[892,4342,4343],{},"Add an adapter, done",[748,4345,4346],{},"Generic ETL tools work well for standardised APIs and databases. But music royalty data doesn't come from APIs - it comes from email attachments, FTP servers, and distributor portals. Each source is its own special case. That's why an adapter-based approach wins here: each distributor gets its own parser, isolated from the rest, easy to update when formats change.",[758,4348,4350],{"id":4349},"one-clean-dataset","One clean dataset",[748,4352,4353],{},"Here's what the pipeline looks like in practice:",[1339,4355,4357],{"className":1341,"code":4356,"language":1343,"meta":1200,"style":1200},"flowchart LR\n    subgraph sources[\"Raw Files\"]\n        A[\".xlsx\"]\n        B[\".xlsb\"]\n        C[\".xls\"]\n        D[\".csv\"]\n    end\n\n    subgraph adapters[\"Adapter Layer\"]\n        E[\"FUGA\"]\n        F[\"ADA\"]\n        G[\"Orchard\"]\n        H[\"Bandcamp\"]\n        I[\"+ 14 more\"]\n    end\n\n    subgraph output[\"Unified Output\"]\n        J[(\"Clean dataset\")]\n    end\n\n    A --> E\n    B --> F\n    C --> G\n    D --> H\n\n    E --> J\n    F --> J\n    G --> J\n    H --> J\n    I --> J\n",[1345,4358,4359,4363,4368,4373,4378,4383,4388,4392,4396,4401,4406,4411,4416,4421,4426,4430,4434,4439,4444,4448,4452,4457,4462,4466,4471,4475,4480,4485,4490,4496],{"__ignoreMap":1200},[1348,4360,4361],{"class":1350,"line":1351},[1348,4362,3331],{"class":1354},[1348,4364,4365],{"class":1350,"line":1201},[1348,4366,4367],{"class":1354},"    subgraph sources[\"Raw Files\"]\n",[1348,4369,4370],{"class":1350,"line":1211},[1348,4371,4372],{"class":1354},"        A[\".xlsx\"]\n",[1348,4374,4375],{"class":1350,"line":1368},[1348,4376,4377],{"class":1354},"        B[\".xlsb\"]\n",[1348,4379,4380],{"class":1350,"line":1374},[1348,4381,4382],{"class":1354},"        C[\".xls\"]\n",[1348,4384,4385],{"class":1350,"line":1380},[1348,4386,4387],{"class":1354},"        D[\".csv\"]\n",[1348,4389,4390],{"class":1350,"line":1386},[1348,4391,3371],{"class":1354},[1348,4393,4394],{"class":1350,"line":1392},[1348,4395,1395],{"emptyLinePlaceholder":1231},[1348,4397,4398],{"class":1350,"line":1398},[1348,4399,4400],{"class":1354},"    subgraph adapters[\"Adapter Layer\"]\n",[1348,4402,4403],{"class":1350,"line":1404},[1348,4404,4405],{"class":1354},"        E[\"FUGA\"]\n",[1348,4407,4408],{"class":1350,"line":1410},[1348,4409,4410],{"class":1354},"        F[\"ADA\"]\n",[1348,4412,4413],{"class":1350,"line":1416},[1348,4414,4415],{"class":1354},"        G[\"Orchard\"]\n",[1348,4417,4418],{"class":1350,"line":1422},[1348,4419,4420],{"class":1354},"        H[\"Bandcamp\"]\n",[1348,4422,4423],{"class":1350,"line":1428},[1348,4424,4425],{"class":1354},"        I[\"+ 14 more\"]\n",[1348,4427,4428],{"class":1350,"line":1434},[1348,4429,3371],{"class":1354},[1348,4431,4432],{"class":1350,"line":1440},[1348,4433,1395],{"emptyLinePlaceholder":1231},[1348,4435,4436],{"class":1350,"line":1446},[1348,4437,4438],{"class":1354},"    subgraph output[\"Unified Output\"]\n",[1348,4440,4441],{"class":1350,"line":1452},[1348,4442,4443],{"class":1354},"        J[(\"Clean dataset\")]\n",[1348,4445,4446],{"class":1350,"line":1814},[1348,4447,3371],{"class":1354},[1348,4449,4450],{"class":1350,"line":1826},[1348,4451,1395],{"emptyLinePlaceholder":1231},[1348,4453,4454],{"class":1350,"line":1849},[1348,4455,4456],{"class":1354},"    A --> E\n",[1348,4458,4459],{"class":1350,"line":2639},[1348,4460,4461],{"class":1354},"    B --> F\n",[1348,4463,4464],{"class":1350,"line":2645},[1348,4465,3408],{"class":1354},[1348,4467,4468],{"class":1350,"line":2698},[1348,4469,4470],{"class":1354},"    D --> H\n",[1348,4472,4473],{"class":1350,"line":2721},[1348,4474,1395],{"emptyLinePlaceholder":1231},[1348,4476,4477],{"class":1350,"line":2746},[1348,4478,4479],{"class":1354},"    E --> J\n",[1348,4481,4482],{"class":1350,"line":2751},[1348,4483,4484],{"class":1354},"    F --> J\n",[1348,4486,4487],{"class":1350,"line":2757},[1348,4488,4489],{"class":1354},"    G --> J\n",[1348,4491,4493],{"class":1350,"line":4492},29,[1348,4494,4495],{"class":1354},"    H --> J\n",[1348,4497,4499],{"class":1350,"line":4498},30,[1348,4500,4501],{"class":1354},"    I --> J\n",[748,4503,4504],{},"Every file goes through its format-specific adapter - handling encoding, column mapping, date parsing, and multi-sheet logic. What comes out the other side is one consistent dataset: same columns, same date format, same encoding. Ready for analysis, reporting, and artist payouts.",[748,4506,4507,4508,4511,4512,4515],{},"But consistent columns are only the beginning. The data inside those files is ",[1483,4509,4510],{"href":81},"just as messy"," - 830 raw values that need mapping to 19 canonical names before you can run a single meaningful query. And once the data is truly clean, it opens the door to ",[1483,4513,4514],{"href":85},"AI-powered analytics"," where business users ask questions in plain English and get charts back in seconds.",[748,4517,4518,4519,4522],{},"That's what we build at MusicTech Lab. Not another dashboard on top of messy data - but the ",[805,4520,4521],{},"data layer underneath"," that turns chaos into clarity.",[758,4524,4526],{"id":4525},"looks-familiar","Looks familiar?",[748,4528,4529],{},"If your monthly royalty workflow involves more spreadsheet wrangling than actual analysis, we should talk. We've built data pipelines for independent labels handling exactly this kind of complexity - and we can do the same for you.",[3126,4531,3819],{},{"title":1200,"searchDepth":1201,"depth":1201,"links":4533},[4534,4535,4536,4537,4538,4539],{"id":3883,"depth":1201,"text":3884},{"id":4178,"depth":1201,"text":4179},{"id":4207,"depth":1201,"text":4208},{"id":4239,"depth":1201,"text":4240},{"id":4349,"depth":1201,"text":4350},{"id":4525,"depth":1201,"text":4526},"2026-02-27T00:00:00.000Z","Every month, independent labels receive royalty reports from over a dozen distributors. No two look the same. Here's what that actually looks like.",[4543,4546,4549],{"question":4544,"answer":4545},"Why do music distributors use different file formats?","There is no industry-wide standard for royalty report delivery. Each distributor built their own reporting system independently, resulting in different file formats, column names, date conventions, and encodings.",{"question":4547,"answer":4548},"What file formats are used in music royalty reporting?","Common formats include XLSX (modern Excel), XLSB (binary Excel), XLS (legacy Excel), and CSV with various encodings (UTF-8, UTF-16, ISO-8859-1). Some distributors send the same report in multiple formats.",{"question":4550,"answer":4551},"How can labels automate royalty data processing?","By building format-aware adapters that recognise each distributor's file structure and automatically normalise everything into a single, clean dataset -eliminating manual cleanup and reducing errors.",{"src":4553},"/images/blog/musictechlab_blog_13-distributors-5-file-formats-zero-standards.webp",{"enabled":1231,"items":4555},[4556,4559,4561,4563],{"text":4557,"icon":4558},"18 adapters parse 500+ files per year from 13 distributors in 5 different formats.","i-lucide-blocks",{"text":4560,"icon":799},"File sizes range from 6 KB (Bandcamp) to 700 MB (The Orchard) with zero naming standards.",{"text":4562,"icon":3851},"Adapter-based pipelines isolate each source, making format changes safe and side-effect-free.",{"text":4564,"icon":1238},"Manual spreadsheet processing delays royalty payments and introduces silent reporting errors.",{},{"title":4567,"description":4568},"Music Royalty Data Chaos: 13 Distributors, 5 Formats | MusicTech Lab","See the real complexity of music royalty reporting -different file formats, naming conventions, and schemas from every distributor. Learn how automation solves it.",[1216,1252,4570,3862,3863],"royalties","RALDAG90DAM9IgwQPuVrQkFWVhTDlAAddo9XQWvnld4",{"id":4573,"title":132,"authors":4574,"badge":4580,"body":4583,"category":1216,"client":1217,"date":4744,"description":4745,"extension":1220,"faq":1217,"featured":69,"featuredOrder":1217,"hidden":69,"image":4746,"keyTakeaways":4748,"meta":4758,"navigation":1231,"path":133,"seo":4759,"status":1217,"stem":134,"tags":4760,"teaser":1217,"__hash__":4762,"score":1211},"posts/blog/music-data/ddex-office-hours-musictech.md",[4575],{"name":4576,"to":4577,"avatar":4578},"Maciej Dulski","https://www.linkedin.com/in/maciej-dulski/",{"src":4579},"/images/cdn-migrated/maciej-dulski-400x400.webp",{"label":4581,"color":4582},"Featured","#E91E63",{"type":745,"value":4584,"toc":4738},[4585,4591,4603,4610,4614,4633,4636,4640,4643,4660,4665,4669,4676,4690,4693,4697,4712,4720],[748,4586,4587,4588,1554],{},"If you're building a MusicTech platform that needs to integrate with labels, distributors, or DSPs, DDEX compliance becomes unavoidable at some point. The challenge is knowing ",[805,4589,4590],{},"what to implement, when, and how much is actually required",[748,4592,4593,4594,4602],{},"MusicTech Lab is a ",[805,4595,4596],{},[1483,4597,4601],{"href":4598,"rel":4599},"https://ddex.net/membership/current-members/",[4600],"nofollow","DDEX member",", and these office hours are run by our DDEX expert — based on hands-on experience with production systems used across the music industry.",[748,4604,4605,4606,4609],{},"We offer ",[805,4607,4608],{},"2 free DDEX office hour slots per month",", focused on practical guidance.",[758,4611,4613],{"id":4612},"what-we-can-help-with","What We Can Help With",[788,4615,4617,4621,4625,4629],{"className":4616},[791,792,793,1459,795],[797,4618],{"description":4619,"title":4620},"Choosing the right DDEX standard for your use case — ERN, DSR, RIN, or MLC — and understanding the real-world differences between ERN versions.","Standards Selection",[797,4622],{"description":4623,"title":4624},"Reviewing your internal data model for DDEX readiness. Mapping Releases, Resources, Deals, and Parties to your existing structures.","Data Model Preparation",[797,4626],{"description":4627,"title":4628},"Designing ERN export APIs, including OpenAPI-based approaches. Handling identifiers for artists, contributors, and rights holders correctly.","API Design",[797,4630],{"description":4631,"title":4632},"Common compliance pitfalls, validation strategies, and how to test your DDEX output before sending it to DSPs.","Validation & Compliance",[748,4634,4635],{},"You can bring a specific technical problem or ask us to review your overall approach.",[758,4637,4639],{"id":4638},"common-questions-weve-answered","Common Questions We've Answered",[748,4641,4642],{},"Here are real examples of what teams bring to office hours:",[982,4644,4645,4648,4651,4654,4657],{},[985,4646,4647],{},"\"We have track metadata in our database — how do we map it to ERN 3.8?\"",[985,4649,4650],{},"\"Our distributor is asking for DDEX-compliant delivery. Where do we start?\"",[985,4652,4653],{},"\"We're building an export pipeline. Should we generate XML directly or use an intermediary format?\"",[985,4655,4656],{},"\"How do we handle multi-territory deals and release windows?\"",[985,4658,4659],{},"\"What's the minimum viable ERN file that YouTube/Spotify will accept?\"",[772,4661,4662],{},[748,4663,4664],{},"These aren't theoretical discussions. Every session is focused on your specific implementation — your data model, your tech stack, your timeline.",[758,4666,4668],{"id":4667},"real-implementation-experience","Real Implementation Experience",[748,4670,4671,4672,4675],{},"We recently supported a music creation platform in preparing their internal data model for ",[805,4673,4674],{},"DDEX ERN-based metadata delivery",". The engagement included:",[982,4677,4678,4681,4684,4687],{},[985,4679,4680],{},"Data model review and gap analysis for DDEX compliance",[985,4682,4683],{},"Mapping internal fields to ERN structures",[985,4685,4686],{},"Defining export specifications for ERN 3.8 with future upgrade paths",[985,4688,4689],{},"Documentation, examples, and validation recommendations",[748,4691,4692],{},"This gave the client a clear, compliant foundation to move toward distributor and DSP integrations.",[758,4694,4696],{"id":4695},"how-it-works","How It Works",[788,4698,4700,4704,4708],{"className":4699},[791,792,1111,1459,795],[797,4701],{"description":4702,"title":4703},"First come, first served. Each session is 30–45 minutes.","2 free slots per month",[797,4705],{"description":4706,"title":4707},"Bring your code, data models, or architecture diagrams. We'll review and advise.","Technical consultation",[797,4709],{"description":4710,"title":4711},"No sales pitch. Just practical help with real implementation choices.","Focused on decisions",[855,4713,4714],{},[748,4715,4716,4717,1554],{},"These office hours are provided by MusicTech Lab. MusicTech Lab is a DDEX member, but this service is ",[805,4718,4719],{},"not endorsed, operated, or provided by DDEX",[788,4721,4725,4733],{"className":4722},[3320,4723,4724,795],"flex-wrap","gap-3",[4726,4727],"u-button",{"color":4728,"label":4729,"target":4730,"to":4731,"variant":4732},"primary","Book a Session","_blank","https://musictechlab.io/contact","subtle",[4726,4734],{"color":4735,"label":4736,"target":4730,"to":4737,"variant":4732},"neutral","DDEX Integration Services","https://musictechlab.io/lp/ddex-integration",{"title":1200,"searchDepth":1201,"depth":1201,"links":4739},[4740,4741,4742,4743],{"id":4612,"depth":1201,"text":4613},{"id":4638,"depth":1201,"text":4639},{"id":4667,"depth":1201,"text":4668},{"id":4695,"depth":1201,"text":4696},"2025-12-22T00:00:00.000Z","Free DDEX office hours for MusicTech teams. Get help with DDEX compliance, ERN implementation, data models, and integrations with DSPs.",{"src":4747},"/images/blog/musictechlab_blog_musictech-lab-ddex-office-hours.webp",{"enabled":1231,"items":4749},[4750,4752,4755],{"text":4751,"icon":1241},"MusicTech Lab offers 2 free DDEX office hour slots per month for hands-on technical guidance.",{"text":4753,"icon":4754},"Sessions cover standards selection, data model mapping, API design, and validation strategies.","i-lucide-file-code",{"text":4756,"icon":4757},"Every session is focused on your specific implementation, not theoretical discussions.","i-lucide-lightbulb",{},{"title":132,"description":4745},[4761,1252,3863],"DDEX","Ixad9_iid0eEMjDHYM-lp2qGTEmyHS6lFV2v0lZz3_c",1780305147546]